@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,1063 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Prints what is stuck in a Luca run, and why, from its journal, for the
|
|
4
|
+
* `/luca-unstick` skill:
|
|
5
|
+
*
|
|
6
|
+
* bun stuck-summary.ts [run id] [#ticket] [--repo <path>] [--runs-dir <dir>] [--registry <file>]
|
|
7
|
+
*
|
|
8
|
+
* With no run id, it lists the runs of the repo (`--repo`, else the git repo
|
|
9
|
+
* of the current folder), newest first, and sums up the newest one that
|
|
10
|
+
* needs you. With `#ticket`, it sums up only that ticket (and the run).
|
|
11
|
+
*
|
|
12
|
+
* It only reads: the journal (`<runs folder>/<run id>/journal.jsonl`), the
|
|
13
|
+
* board's run registry (never its tokens), the engine's log, and `ps`. It
|
|
14
|
+
* changes nothing.
|
|
15
|
+
*
|
|
16
|
+
* It imports nothing but Bun's and Node's own modules: it runs from
|
|
17
|
+
* `~/.claude/skills/luca-unstick/`, where no packages are installed. So it
|
|
18
|
+
* reads records loosely, not with the engine's Zod schemas.
|
|
19
|
+
* `stuck-summary.test.ts` checks it against journals built with the
|
|
20
|
+
* engine's own fixtures, and every kind in `KINDS_READ` against the
|
|
21
|
+
* journal's kinds, so the two can't drift apart unseen.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
24
|
+
import { homedir } from 'node:os'
|
|
25
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
26
|
+
|
|
27
|
+
/** Every journal record kind this script reads. */
|
|
28
|
+
export const KINDS_READ = [
|
|
29
|
+
'run_started',
|
|
30
|
+
'engine_resumed',
|
|
31
|
+
'intake_read',
|
|
32
|
+
'spec_snapshot',
|
|
33
|
+
'ticket_snapshot',
|
|
34
|
+
'intake_refused',
|
|
35
|
+
'nothing_to_do',
|
|
36
|
+
'run_branch_created',
|
|
37
|
+
'ticket_worktree_created',
|
|
38
|
+
'baseline_prepared',
|
|
39
|
+
'agent_finished',
|
|
40
|
+
'agent_failed',
|
|
41
|
+
'red_check',
|
|
42
|
+
'already_done_checked',
|
|
43
|
+
'leftover_scan',
|
|
44
|
+
'gates_run',
|
|
45
|
+
'ticket_rebased',
|
|
46
|
+
'tests_sent_back',
|
|
47
|
+
'ticket_stuck',
|
|
48
|
+
'run_stuck',
|
|
49
|
+
'final_review_stuck',
|
|
50
|
+
'stuck_reported',
|
|
51
|
+
'comment_read',
|
|
52
|
+
'reply_received',
|
|
53
|
+
'reply_ignored',
|
|
54
|
+
'ticket_retried',
|
|
55
|
+
'ticket_skipped',
|
|
56
|
+
'final_review_retried',
|
|
57
|
+
'final_review_shipped',
|
|
58
|
+
'run_stopped',
|
|
59
|
+
'pull_request_opened',
|
|
60
|
+
'limit_wait_started',
|
|
61
|
+
'limit_wait_ended',
|
|
62
|
+
'step_started',
|
|
63
|
+
'step_ended',
|
|
64
|
+
] as const
|
|
65
|
+
|
|
66
|
+
type KindRead = (typeof KINDS_READ)[number]
|
|
67
|
+
|
|
68
|
+
/** One journal line, read loosely. */
|
|
69
|
+
export type LooseRecord = {
|
|
70
|
+
seq: number
|
|
71
|
+
time: string
|
|
72
|
+
kind: string
|
|
73
|
+
ticket: number | null
|
|
74
|
+
content: Record<string, unknown>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Longest text quoted from a record, such as a stuck detail. */
|
|
78
|
+
const DETAIL_MAX = 2000
|
|
79
|
+
|
|
80
|
+
/** Longest command output quoted: its end, where the error usually is. */
|
|
81
|
+
const OUTPUT_MAX = 1500
|
|
82
|
+
|
|
83
|
+
/** Engine log lines shown for a crash. */
|
|
84
|
+
const LOG_LINES = 25
|
|
85
|
+
|
|
86
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
87
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
88
|
+
|
|
89
|
+
const text = (value: unknown): string =>
|
|
90
|
+
typeof value === 'string' ? value : ''
|
|
91
|
+
|
|
92
|
+
const numberOr = (value: unknown): number | null =>
|
|
93
|
+
typeof value === 'number' ? value : null
|
|
94
|
+
|
|
95
|
+
const listOf = (value: unknown): unknown[] =>
|
|
96
|
+
Array.isArray(value) ? value : []
|
|
97
|
+
|
|
98
|
+
const objectOf = (value: unknown): Record<string, unknown> =>
|
|
99
|
+
isObject(value) ? value : {}
|
|
100
|
+
|
|
101
|
+
/** The first `max` characters, marked when cut. */
|
|
102
|
+
const clip = (value: string, max = DETAIL_MAX): string =>
|
|
103
|
+
value.length > max ? `${value.slice(0, max)} ... (clipped)` : value
|
|
104
|
+
|
|
105
|
+
/** The last `max` characters, marked when cut. */
|
|
106
|
+
const clipEnd = (value: string, max = OUTPUT_MAX): string =>
|
|
107
|
+
value.length > max ? `(clipped) ... ${value.slice(-max)}` : value
|
|
108
|
+
|
|
109
|
+
/** A journal record's seq as `seq 123`, never `#123`, which GitHub links. */
|
|
110
|
+
const seqText = (seq: number): string => `seq ${seq}`
|
|
111
|
+
|
|
112
|
+
/** Each line of each entry, after `by`. */
|
|
113
|
+
const nest = (entries: string[], by: string): string[] =>
|
|
114
|
+
entries.flatMap((entry) => entry.split('\n')).map((line) => `${by}${line}`)
|
|
115
|
+
|
|
116
|
+
const indent = (value: string, by = ' '): string =>
|
|
117
|
+
value
|
|
118
|
+
.split('\n')
|
|
119
|
+
.map((line) => `${by}${line}`)
|
|
120
|
+
.join('\n')
|
|
121
|
+
|
|
122
|
+
/** One journal line as a loose record, or `null` when it isn't one. */
|
|
123
|
+
const toRecord = (line: string): LooseRecord | null => {
|
|
124
|
+
let json: unknown
|
|
125
|
+
try {
|
|
126
|
+
json = JSON.parse(line)
|
|
127
|
+
} catch {
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
if (!isObject(json)) return null
|
|
131
|
+
const seq = numberOr(json.seq)
|
|
132
|
+
if (seq === null || typeof json.kind !== 'string') return null
|
|
133
|
+
return {
|
|
134
|
+
seq,
|
|
135
|
+
time: text(json.time),
|
|
136
|
+
kind: json.kind,
|
|
137
|
+
ticket: numberOr(json.ticket),
|
|
138
|
+
content: objectOf(json.content),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A run's records, oldest first. Lines that aren't records (such as a
|
|
144
|
+
* half-written last line) are skipped. No journal gives none.
|
|
145
|
+
*/
|
|
146
|
+
export const readRecords = ({ file }: { file: string }): LooseRecord[] =>
|
|
147
|
+
existsSync(file)
|
|
148
|
+
? readFileSync(file, 'utf8')
|
|
149
|
+
.split('\n')
|
|
150
|
+
.filter((line) => line.trim() !== '')
|
|
151
|
+
.map(toRecord)
|
|
152
|
+
.filter((record): record is LooseRecord => record !== null)
|
|
153
|
+
: []
|
|
154
|
+
|
|
155
|
+
const ofKind = (records: LooseRecord[], kind: KindRead): LooseRecord[] =>
|
|
156
|
+
records.filter((record) => record.kind === kind)
|
|
157
|
+
|
|
158
|
+
const lastOf = (
|
|
159
|
+
records: LooseRecord[],
|
|
160
|
+
kind: KindRead,
|
|
161
|
+
where: (record: LooseRecord) => boolean = () => true
|
|
162
|
+
): LooseRecord | undefined => ofKind(records, kind).filter(where).at(-1)
|
|
163
|
+
|
|
164
|
+
/** Something that waits on the spec owner's reply. */
|
|
165
|
+
export type StuckItem = {
|
|
166
|
+
/** The stuck ticket, or `null` for the final review or the whole run. */
|
|
167
|
+
ticket: number | null
|
|
168
|
+
what: 'ticket' | 'final_review' | 'run'
|
|
169
|
+
/** The record that made it stuck, such as `ticket_stuck`. */
|
|
170
|
+
kind: string
|
|
171
|
+
reason: string
|
|
172
|
+
detail: string
|
|
173
|
+
seq: number
|
|
174
|
+
/** A `retry` the engine refused (the edited ticket isn't ready). */
|
|
175
|
+
refused: string[]
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* What waits on the owner now: stuck tickets not yet retried or skipped,
|
|
180
|
+
* a stuck final review not yet retried or shipped, and the run stuck on its
|
|
181
|
+
* budget until a bare `retry`. Pure.
|
|
182
|
+
*/
|
|
183
|
+
export const openStuck = ({
|
|
184
|
+
records,
|
|
185
|
+
}: {
|
|
186
|
+
records: LooseRecord[]
|
|
187
|
+
}): StuckItem[] => {
|
|
188
|
+
const tickets = new Map<number, StuckItem>()
|
|
189
|
+
let final: StuckItem | null = null
|
|
190
|
+
let run: StuckItem | null = null
|
|
191
|
+
for (const record of records) {
|
|
192
|
+
const { kind, ticket, content, seq } = record
|
|
193
|
+
const item = (what: StuckItem['what']): StuckItem => ({
|
|
194
|
+
ticket,
|
|
195
|
+
what,
|
|
196
|
+
kind,
|
|
197
|
+
reason: text(content.reason),
|
|
198
|
+
detail: text(content.detail),
|
|
199
|
+
seq,
|
|
200
|
+
refused: [],
|
|
201
|
+
})
|
|
202
|
+
if (kind === 'ticket_stuck' && ticket !== null) {
|
|
203
|
+
tickets.set(ticket, item('ticket'))
|
|
204
|
+
} else if (kind === 'ticket_retried' && ticket !== null) {
|
|
205
|
+
const stuck = tickets.get(ticket)
|
|
206
|
+
if (content.mode === 'refused' && stuck !== undefined) {
|
|
207
|
+
stuck.refused = listOf(content.problems).map(text)
|
|
208
|
+
} else {
|
|
209
|
+
tickets.delete(ticket)
|
|
210
|
+
}
|
|
211
|
+
} else if (kind === 'ticket_skipped' && ticket !== null) {
|
|
212
|
+
tickets.delete(ticket)
|
|
213
|
+
} else if (kind === 'final_review_stuck') {
|
|
214
|
+
final = item('final_review')
|
|
215
|
+
} else if (
|
|
216
|
+
kind === 'final_review_retried' ||
|
|
217
|
+
kind === 'final_review_shipped'
|
|
218
|
+
) {
|
|
219
|
+
final = null
|
|
220
|
+
} else if (kind === 'run_stuck') {
|
|
221
|
+
run = item('run')
|
|
222
|
+
} else if (
|
|
223
|
+
kind === 'reply_received' &&
|
|
224
|
+
content.word === 'retry' &&
|
|
225
|
+
content.ticket === null &&
|
|
226
|
+
run !== null
|
|
227
|
+
) {
|
|
228
|
+
run = null
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return [
|
|
232
|
+
...(run === null ? [] : [run]),
|
|
233
|
+
...[...tickets.values()],
|
|
234
|
+
...(final === null ? [] : [final]),
|
|
235
|
+
]
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** How the run stands, from its last records. Pure. */
|
|
239
|
+
export const runState = ({
|
|
240
|
+
records,
|
|
241
|
+
}: {
|
|
242
|
+
records: LooseRecord[]
|
|
243
|
+
}): { over: boolean; line: string } => {
|
|
244
|
+
const pr = lastOf(records, 'pull_request_opened')
|
|
245
|
+
if (pr !== undefined) {
|
|
246
|
+
return { over: true, line: `It opened its PR: ${text(pr.content.url)}` }
|
|
247
|
+
}
|
|
248
|
+
const refused = lastOf(records, 'intake_refused')
|
|
249
|
+
if (refused !== undefined) {
|
|
250
|
+
const problems = listOf(refused.content.problems)
|
|
251
|
+
.map(objectOf)
|
|
252
|
+
.map(
|
|
253
|
+
(problem) =>
|
|
254
|
+
`${problem.ticket === null ? 'the run' : `#${String(problem.ticket)}`} misses ${listOf(problem.missing).map(text).join(', ')}`
|
|
255
|
+
)
|
|
256
|
+
return {
|
|
257
|
+
over: true,
|
|
258
|
+
line: `Intake refused it: ${problems.join('; ') || 'see the spec issue'}.`,
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (lastOf(records, 'nothing_to_do') !== undefined) {
|
|
262
|
+
return { over: true, line: 'It ended with nothing to do.' }
|
|
263
|
+
}
|
|
264
|
+
const stopped = lastOf(records, 'run_stopped')
|
|
265
|
+
const resumed = lastOf(records, 'engine_resumed')
|
|
266
|
+
if (stopped !== undefined) {
|
|
267
|
+
const reason = text(stopped.content.reason)
|
|
268
|
+
if (stopped.content.billing === true) {
|
|
269
|
+
return {
|
|
270
|
+
over: true,
|
|
271
|
+
line: `It stopped for good on a billing sign: ${reason}`,
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (stopped.content.crashed === true) {
|
|
275
|
+
return {
|
|
276
|
+
over: true,
|
|
277
|
+
line: `It stopped for good: the same step crashed again and again. ${reason}`,
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (resumed === undefined || resumed.seq < stopped.seq) {
|
|
281
|
+
return {
|
|
282
|
+
over: false,
|
|
283
|
+
line: `The launcher stopped it: ${reason}`,
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const stop = lastOf(
|
|
288
|
+
records,
|
|
289
|
+
'reply_received',
|
|
290
|
+
({ content }) => content.word === 'stop'
|
|
291
|
+
)
|
|
292
|
+
if (stop !== undefined) {
|
|
293
|
+
return {
|
|
294
|
+
over: true,
|
|
295
|
+
line: `The owner replied stop (${text(stop.content.author)}), so it ends without a PR.`,
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const wait = lastOf(records, 'limit_wait_started')
|
|
299
|
+
const waited = lastOf(records, 'limit_wait_ended')
|
|
300
|
+
if (wait !== undefined && (waited === undefined || waited.seq < wait.seq)) {
|
|
301
|
+
return {
|
|
302
|
+
over: false,
|
|
303
|
+
line: `It waits for a plan limit until ${text(wait.content.until)}. That is not stuck: it goes on by itself.`,
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return { over: false, line: 'It is not over.' }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The steps a crash cut off: started, never ended. Pure. */
|
|
310
|
+
export const cutOffSteps = ({
|
|
311
|
+
records,
|
|
312
|
+
}: {
|
|
313
|
+
records: LooseRecord[]
|
|
314
|
+
}): { key: string; step: string; seq: number }[] => {
|
|
315
|
+
const open = new Map<string, { key: string; step: string; seq: number }>()
|
|
316
|
+
for (const { kind, content, seq } of records) {
|
|
317
|
+
const key = text(content.key)
|
|
318
|
+
if (kind === 'step_started') {
|
|
319
|
+
open.set(key, { key, step: text(content.step), seq })
|
|
320
|
+
} else if (kind === 'step_ended') {
|
|
321
|
+
open.delete(key)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return [...open.values()]
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** `owner/repo` from a GitHub issue URL, or `null`. Pure. */
|
|
328
|
+
export const repoOfUrl = (url: string): string | null =>
|
|
329
|
+
/^https:\/\/github\.com\/([^/]+\/[^/]+)\//.exec(url)?.[1] ?? null
|
|
330
|
+
|
|
331
|
+
/** A failed check's lines: its name, command, how it ended, its output's end. */
|
|
332
|
+
const checkLines = (check: Record<string, unknown>): string[] => {
|
|
333
|
+
const exit = numberOr(check.exit_code)
|
|
334
|
+
return [
|
|
335
|
+
`${text(check.name) || 'check'} \`${text(check.command)}\` ${exit === null ? 'timed out (no exit code)' : `exit ${exit}`}`,
|
|
336
|
+
indent(clipEnd(text(check.output)).trim() || '(no output)'),
|
|
337
|
+
]
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** A reviewer's blocking findings (blockers and should-fixes), one per line. */
|
|
341
|
+
const findingLines = (result: Record<string, unknown>): string[] =>
|
|
342
|
+
listOf(result.findings)
|
|
343
|
+
.map(objectOf)
|
|
344
|
+
.filter((finding) => finding.severity !== 'nit')
|
|
345
|
+
.map(
|
|
346
|
+
(finding) =>
|
|
347
|
+
`- ${text(finding.severity)} (${text(finding.kind)})${text(finding.file) === '' ? '' : ` ${text(finding.file)}`}: ${clip(text(finding.title), 300)}`
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
/** What the journal says about the stuck final review. Pure. */
|
|
351
|
+
const finalReviewLines = ({
|
|
352
|
+
records,
|
|
353
|
+
}: {
|
|
354
|
+
records: LooseRecord[]
|
|
355
|
+
}): string[] => {
|
|
356
|
+
const lines: string[] = []
|
|
357
|
+
const run_level = records.filter((record) => record.ticket === null)
|
|
358
|
+
const lenses = new Map<string, LooseRecord>()
|
|
359
|
+
for (const record of ofKind(run_level, 'agent_finished')) {
|
|
360
|
+
const role = text(record.content.role)
|
|
361
|
+
if (role.endsWith('-lens')) lenses.set(role, record)
|
|
362
|
+
}
|
|
363
|
+
for (const [role, record] of lenses) {
|
|
364
|
+
const findings = findingLines(objectOf(record.content.result))
|
|
365
|
+
if (findings.length > 0) {
|
|
366
|
+
lines.push(
|
|
367
|
+
`${role}'s open findings (${seqText(record.seq)}):`,
|
|
368
|
+
...findings
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const failed = lastOf(run_level, 'agent_failed')
|
|
373
|
+
if (failed !== undefined) {
|
|
374
|
+
lines.push(
|
|
375
|
+
`Last failed try (${seqText(failed.seq)}): ${text(failed.content.role)} (${text(failed.content.failure) || 'agent'})`,
|
|
376
|
+
indent(clip(text(failed.content.error), 800))
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
const gates = lastOf(run_level, 'gates_run')
|
|
380
|
+
if (gates !== undefined && gates.content.ok === false) {
|
|
381
|
+
lines.push(
|
|
382
|
+
`Last gates on the run branch failed (${seqText(gates.seq)}):`,
|
|
383
|
+
...listOf(gates.content.checks)
|
|
384
|
+
.map(objectOf)
|
|
385
|
+
.filter((check) => check.ok === false)
|
|
386
|
+
.flatMap(checkLines)
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
return lines
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** What the journal says about one stuck ticket. Pure. */
|
|
393
|
+
const ticketLines = ({
|
|
394
|
+
records,
|
|
395
|
+
item,
|
|
396
|
+
}: {
|
|
397
|
+
records: LooseRecord[]
|
|
398
|
+
item: StuckItem
|
|
399
|
+
}): string[] => {
|
|
400
|
+
const n = item.ticket
|
|
401
|
+
const mine = records.filter((record) => record.ticket === n)
|
|
402
|
+
const lines: string[] = []
|
|
403
|
+
const worktree = lastOf(mine, 'ticket_worktree_created')
|
|
404
|
+
if (worktree !== undefined) {
|
|
405
|
+
lines.push(
|
|
406
|
+
`Worktree: ${text(worktree.content.path)} (branch ${text(worktree.content.branch)})`
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
const agent = lastOf(mine, 'agent_finished')
|
|
410
|
+
if (agent !== undefined) {
|
|
411
|
+
const role = text(agent.content.role)
|
|
412
|
+
const result = objectOf(agent.content.result)
|
|
413
|
+
const outcome = text(result.outcome) || text(result.verdict)
|
|
414
|
+
lines.push(
|
|
415
|
+
`Last agent result (${seqText(agent.seq)}): ${role} ${outcome}`
|
|
416
|
+
)
|
|
417
|
+
const bad = objectOf(result.bad_test)
|
|
418
|
+
if (text(bad.file) !== '') {
|
|
419
|
+
lines.push(
|
|
420
|
+
` bad_test: ${text(bad.file)} > ${text(bad.name)}`,
|
|
421
|
+
indent(`reason: ${clip(text(bad.reason))}`)
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
lines.push(...nest(findingLines(result), ' '))
|
|
425
|
+
const setup = objectOf(result.setup_change)
|
|
426
|
+
if (text(setup.file) !== '') {
|
|
427
|
+
lines.push(
|
|
428
|
+
` setup_change: ${text(setup.file)}: ${clip(text(setup.reason))}`
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
const done_by = listOf(result.done_by).map(objectOf)
|
|
432
|
+
if (done_by.length > 0) {
|
|
433
|
+
lines.push(
|
|
434
|
+
` done_by: ${done_by.map((commit) => `${text(commit.sha).slice(0, 9)} ${text(commit.title)}`).join('; ')}`
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
if (text(result.summary) !== '') {
|
|
438
|
+
lines.push(indent(`summary: ${clip(text(result.summary), 600)}`))
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const failed = lastOf(mine, 'agent_failed')
|
|
442
|
+
if (failed !== undefined) {
|
|
443
|
+
lines.push(
|
|
444
|
+
`Last failed try (${seqText(failed.seq)}): ${text(failed.content.role)} (${text(failed.content.failure) || 'agent'})`,
|
|
445
|
+
indent(clip(text(failed.content.error), 800))
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
const prepared = lastOf(mine, 'baseline_prepared')
|
|
449
|
+
const prepare_check = objectOf(prepared?.content.check)
|
|
450
|
+
if (prepared !== undefined && prepare_check.ok === false) {
|
|
451
|
+
lines.push(
|
|
452
|
+
`Prepare failed (${seqText(prepared.seq)}):`,
|
|
453
|
+
...checkLines(prepare_check)
|
|
454
|
+
)
|
|
455
|
+
}
|
|
456
|
+
const red = lastOf(mine, 'red_check')
|
|
457
|
+
if (red !== undefined && red.content.ok === false) {
|
|
458
|
+
lines.push(
|
|
459
|
+
`Last red check failed (${seqText(red.seq)}):`,
|
|
460
|
+
...listOf(red.content.problems).map(
|
|
461
|
+
(p) => ` - ${clip(text(p), 600)}`
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
const evidence = lastOf(mine, 'already_done_checked')
|
|
466
|
+
if (evidence !== undefined && evidence.content.ok === false) {
|
|
467
|
+
lines.push(
|
|
468
|
+
`already_done evidence did not check out (${seqText(evidence.seq)}):`,
|
|
469
|
+
...listOf(evidence.content.problems).map(
|
|
470
|
+
(p) => ` - ${clip(text(p), 600)}`
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
const gates = lastOf(mine, 'gates_run')
|
|
475
|
+
if (gates !== undefined && gates.content.ok === false) {
|
|
476
|
+
lines.push(
|
|
477
|
+
`Last gates failed (${seqText(gates.seq)}, on the ${text(gates.content.target) === 'run_branch' ? 'run branch' : 'ticket worktree'}):`,
|
|
478
|
+
...listOf(gates.content.checks)
|
|
479
|
+
.map(objectOf)
|
|
480
|
+
.filter((check) => check.ok === false)
|
|
481
|
+
.flatMap(checkLines)
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
const scan = lastOf(mine, 'leftover_scan')
|
|
485
|
+
const hits = listOf(scan?.content.hits).map(objectOf)
|
|
486
|
+
if (hits.length > 0) {
|
|
487
|
+
lines.push(
|
|
488
|
+
'Leftover scan hits:',
|
|
489
|
+
...hits.map((hit) => ` - ${text(hit.path)}: ${text(hit.reason)}`)
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
const rebased = lastOf(mine, 'ticket_rebased')
|
|
493
|
+
if (rebased !== undefined) {
|
|
494
|
+
lines.push(
|
|
495
|
+
`Rebased onto the run branch (${seqText(rebased.seq)},${text(rebased.content.cause)}): clashed tests ${listOf(rebased.content.tests).map(text).join(', ') || 'none'}; clashed code ${listOf(rebased.content.code).map(text).join(', ') || 'none'}`
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
const sent = lastOf(mine, 'tests_sent_back')
|
|
499
|
+
if (sent !== undefined) {
|
|
500
|
+
const bad = objectOf(sent.content.bad_test)
|
|
501
|
+
lines.push(
|
|
502
|
+
`tests_sent_back round ${String(sent.content.round)} (${seqText(sent.seq)}): ${text(bad.file)} > ${text(bad.name)}`,
|
|
503
|
+
` joined since: ${
|
|
504
|
+
listOf(sent.content.joined)
|
|
505
|
+
.map(objectOf)
|
|
506
|
+
.map((t) => `#${String(t.ticket)} ${text(t.title)}`)
|
|
507
|
+
.join('; ') || 'none'
|
|
508
|
+
}`
|
|
509
|
+
)
|
|
510
|
+
}
|
|
511
|
+
return lines
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** The replies and answers since something got stuck. Pure. */
|
|
515
|
+
const replyLines = ({
|
|
516
|
+
records,
|
|
517
|
+
since,
|
|
518
|
+
}: {
|
|
519
|
+
records: LooseRecord[]
|
|
520
|
+
since: number
|
|
521
|
+
}): string[] =>
|
|
522
|
+
records
|
|
523
|
+
.filter((record) => record.seq > since)
|
|
524
|
+
.flatMap((record): string[] => {
|
|
525
|
+
const { kind, content, seq } = record
|
|
526
|
+
if (kind === 'stuck_reported') {
|
|
527
|
+
return [
|
|
528
|
+
`${seqText(seq)} stuck_reported: comment ${String(content.comment_id)}`,
|
|
529
|
+
]
|
|
530
|
+
}
|
|
531
|
+
if (kind === 'comment_read') {
|
|
532
|
+
return [
|
|
533
|
+
`${seqText(seq)} comment_read by ${text(content.author)}: ${clip(text(content.body).replace(/\s+/g, ' '), 200)}`,
|
|
534
|
+
]
|
|
535
|
+
}
|
|
536
|
+
if (kind === 'reply_received') {
|
|
537
|
+
return [
|
|
538
|
+
`${seqText(seq)} reply_received: ${text(content.word)}${content.ticket === null ? '' : ` #${String(content.ticket)}`} from ${text(content.author)}`,
|
|
539
|
+
]
|
|
540
|
+
}
|
|
541
|
+
if (kind === 'reply_ignored') {
|
|
542
|
+
return [
|
|
543
|
+
`${seqText(seq)} reply_ignored: ${text(content.reason)}`,
|
|
544
|
+
]
|
|
545
|
+
}
|
|
546
|
+
if (kind === 'ticket_retried') {
|
|
547
|
+
return [
|
|
548
|
+
`${seqText(seq)} ticket_retried #${String(record.ticket)}: ${text(content.mode)}${listOf(content.problems).length === 0 ? '' : ` (${listOf(content.problems).map(text).join('; ')})`}`,
|
|
549
|
+
]
|
|
550
|
+
}
|
|
551
|
+
if (
|
|
552
|
+
kind === 'ticket_skipped' ||
|
|
553
|
+
kind === 'final_review_retried' ||
|
|
554
|
+
kind === 'final_review_shipped'
|
|
555
|
+
) {
|
|
556
|
+
return [
|
|
557
|
+
`${seqText(seq)} ${kind}${record.ticket === null ? '' : ` #${record.ticket}`}`,
|
|
558
|
+
]
|
|
559
|
+
}
|
|
560
|
+
return []
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
/** The board's registry entry for a run, without its token. */
|
|
564
|
+
export type RegistryEntry = {
|
|
565
|
+
spec: number | null
|
|
566
|
+
repo: string | null
|
|
567
|
+
log_path: string | null
|
|
568
|
+
ended: { ok: boolean; message: string } | null
|
|
569
|
+
restarts: number | null
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** A run's entry in the board's run registry, or `null`. */
|
|
573
|
+
export const registryEntry = ({
|
|
574
|
+
registry,
|
|
575
|
+
run_id,
|
|
576
|
+
}: {
|
|
577
|
+
registry: string
|
|
578
|
+
run_id: string
|
|
579
|
+
}): RegistryEntry | null => {
|
|
580
|
+
if (!existsSync(registry)) return null
|
|
581
|
+
let json: unknown
|
|
582
|
+
try {
|
|
583
|
+
json = JSON.parse(readFileSync(registry, 'utf8'))
|
|
584
|
+
} catch {
|
|
585
|
+
return null
|
|
586
|
+
}
|
|
587
|
+
const entry = listOf(objectOf(json).runs)
|
|
588
|
+
.map(objectOf)
|
|
589
|
+
.find((run) => run.run_id === run_id)
|
|
590
|
+
if (entry === undefined) return null
|
|
591
|
+
const ended = objectOf(entry.ended)
|
|
592
|
+
return {
|
|
593
|
+
spec: numberOr(entry.spec),
|
|
594
|
+
repo: text(entry.repo) || null,
|
|
595
|
+
log_path: text(entry.log_path) || null,
|
|
596
|
+
ended: isObject(entry.ended)
|
|
597
|
+
? { ok: ended.ok === true, message: text(ended.message) }
|
|
598
|
+
: null,
|
|
599
|
+
restarts: numberOr(entry.restarts),
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Whether a run's engine is running now, from `ps`; `null` when unknown. */
|
|
604
|
+
export const engineRunning = ({
|
|
605
|
+
run_id,
|
|
606
|
+
command_lines,
|
|
607
|
+
}: {
|
|
608
|
+
run_id: string
|
|
609
|
+
command_lines: string[] | null
|
|
610
|
+
}): boolean | null => {
|
|
611
|
+
if (command_lines === null) return null
|
|
612
|
+
return command_lines.some((line) => {
|
|
613
|
+
const tokens = line.split(/\s+/)
|
|
614
|
+
return tokens.some(
|
|
615
|
+
(token, index) =>
|
|
616
|
+
((token === '--run-id' || token === '--resume') &&
|
|
617
|
+
tokens[index + 1] === run_id) ||
|
|
618
|
+
token === `--run-id=${run_id}` ||
|
|
619
|
+
token === `--resume=${run_id}`
|
|
620
|
+
)
|
|
621
|
+
})
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Every process's command line, or `null` when `ps` fails. */
|
|
625
|
+
const commandLines = (): string[] | null => {
|
|
626
|
+
const ps = Bun.spawnSync(['ps', '-axww', '-o', 'command='])
|
|
627
|
+
return ps.exitCode === 0 ? ps.stdout.toString().split('\n') : null
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** The last lines of the engine's log, or `null` when it can't be read. */
|
|
631
|
+
const logTail = (path: string | null): string | null => {
|
|
632
|
+
if (path === null || !existsSync(path)) return null
|
|
633
|
+
const lines = readFileSync(path, 'utf8').trimEnd().split('\n')
|
|
634
|
+
return lines.slice(-LOG_LINES).join('\n')
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* The summary of one run, as plain text: the run, how its engine stands,
|
|
639
|
+
* then each stuck item with what the journal says about it and the replies
|
|
640
|
+
* since. `ticket` limits it to that ticket. Pure.
|
|
641
|
+
*/
|
|
642
|
+
export const formatSummary = ({
|
|
643
|
+
run_id,
|
|
644
|
+
records,
|
|
645
|
+
ticket = null,
|
|
646
|
+
registry = null,
|
|
647
|
+
running = null,
|
|
648
|
+
log_tail = null,
|
|
649
|
+
}: {
|
|
650
|
+
run_id: string
|
|
651
|
+
records: LooseRecord[]
|
|
652
|
+
ticket?: number | null
|
|
653
|
+
registry?: RegistryEntry | null
|
|
654
|
+
running?: boolean | null
|
|
655
|
+
log_tail?: string | null
|
|
656
|
+
}): string => {
|
|
657
|
+
if (records.length === 0) return `Run ${run_id}: no journal records.`
|
|
658
|
+
const started = objectOf(lastOf(records, 'run_started')?.content)
|
|
659
|
+
// The snapshot once intake passed, else the spec as intake read it.
|
|
660
|
+
const spec = objectOf(
|
|
661
|
+
objectOf(
|
|
662
|
+
(lastOf(records, 'spec_snapshot') ?? lastOf(records, 'intake_read'))
|
|
663
|
+
?.content
|
|
664
|
+
).spec
|
|
665
|
+
)
|
|
666
|
+
const resumed = lastOf(records, 'engine_resumed')
|
|
667
|
+
const branch = lastOf(records, 'run_branch_created')
|
|
668
|
+
const state = runState({ records })
|
|
669
|
+
const stuck = openStuck({ records }).filter(
|
|
670
|
+
(item) =>
|
|
671
|
+
ticket === null || item.ticket === ticket || item.what !== 'ticket'
|
|
672
|
+
)
|
|
673
|
+
const cut = cutOffSteps({ records })
|
|
674
|
+
const lines: string[] = [
|
|
675
|
+
`Run ${run_id}: spec #${String(started.spec_number ?? spec.number ?? '?')}${text(spec.title) === '' ? '' : ` "${text(spec.title)}"`}`,
|
|
676
|
+
`Repo: ${text(started.repo) || registry?.repo || '?'}${repoOfUrl(text(spec.url)) === null ? '' : ` (${repoOfUrl(text(spec.url))})`}`,
|
|
677
|
+
`Spec owner: ${text(spec.author) || '? (read it with gh issue view)'}`,
|
|
678
|
+
`Luca: started on ${text(started.luca_version) || '?'}${resumed === undefined ? '' : `, last resumed on ${text(resumed.content.luca_version)}`}`,
|
|
679
|
+
]
|
|
680
|
+
if (branch !== undefined) {
|
|
681
|
+
lines.push(
|
|
682
|
+
`Run branch: ${text(branch.content.branch)} at ${text(branch.content.path)}`
|
|
683
|
+
)
|
|
684
|
+
}
|
|
685
|
+
const last = records.at(-1)
|
|
686
|
+
if (last !== undefined) {
|
|
687
|
+
lines.push(
|
|
688
|
+
`Last record: ${seqText(last.seq)} ${last.kind} at ${last.time}`
|
|
689
|
+
)
|
|
690
|
+
}
|
|
691
|
+
lines.push(`State: ${state.line}`)
|
|
692
|
+
lines.push(
|
|
693
|
+
`Engine: ${running === null ? 'unknown (ps failed)' : running ? 'running now, so it will read a reply within about a minute' : 'not running, so nothing reads replies until the run is resumed'}`
|
|
694
|
+
)
|
|
695
|
+
if (registry !== null) {
|
|
696
|
+
lines.push(
|
|
697
|
+
`Board: started by the board${registry.ended === null ? '' : `; it ended: ${registry.ended.message}`}${registry.restarts === null ? '' : `; automatic restarts: ${registry.restarts}`}`
|
|
698
|
+
)
|
|
699
|
+
if (registry.log_path !== null) {
|
|
700
|
+
lines.push(`Engine log: ${registry.log_path}`)
|
|
701
|
+
}
|
|
702
|
+
} else {
|
|
703
|
+
lines.push(
|
|
704
|
+
'Board: not in the board registry (started from a terminal?)'
|
|
705
|
+
)
|
|
706
|
+
}
|
|
707
|
+
if (running === false && !state.over && cut.length > 0) {
|
|
708
|
+
lines.push(
|
|
709
|
+
`Cut off (started, never ended): ${cut.map((step) => `${step.step} on ${step.key} (${seqText(step.seq)})`).join('; ')}`
|
|
710
|
+
)
|
|
711
|
+
}
|
|
712
|
+
if (log_tail !== null && registry?.ended?.ok === false && !state.over) {
|
|
713
|
+
lines.push('Engine log, last lines:', indent(log_tail))
|
|
714
|
+
}
|
|
715
|
+
if (stuck.length === 0) {
|
|
716
|
+
lines.push(
|
|
717
|
+
ticket === null
|
|
718
|
+
? 'Stuck now: nothing waits on a reply.'
|
|
719
|
+
: `Stuck now: #${ticket} doesn't wait on a reply.`
|
|
720
|
+
)
|
|
721
|
+
return lines.join('\n')
|
|
722
|
+
}
|
|
723
|
+
lines.push('Stuck now:')
|
|
724
|
+
for (const item of stuck) {
|
|
725
|
+
const title =
|
|
726
|
+
item.ticket === null
|
|
727
|
+
? ''
|
|
728
|
+
: text(
|
|
729
|
+
lastOf(
|
|
730
|
+
records,
|
|
731
|
+
'ticket_snapshot',
|
|
732
|
+
({ content }) => content.number === item.ticket
|
|
733
|
+
)?.content.title
|
|
734
|
+
)
|
|
735
|
+
const head =
|
|
736
|
+
item.what === 'ticket'
|
|
737
|
+
? `Ticket #${String(item.ticket)}${title === '' ? '' : ` "${title}"`}`
|
|
738
|
+
: item.what === 'final_review'
|
|
739
|
+
? 'The final review'
|
|
740
|
+
: 'The whole run'
|
|
741
|
+
lines.push(
|
|
742
|
+
'',
|
|
743
|
+
`${head}: ${item.reason} (${item.kind} ${seqText(item.seq)})`,
|
|
744
|
+
indent(`detail: ${clip(item.detail)}`)
|
|
745
|
+
)
|
|
746
|
+
if (item.refused.length > 0) {
|
|
747
|
+
lines.push(` A retry was refused: ${item.refused.join('; ')}`)
|
|
748
|
+
}
|
|
749
|
+
if (item.what === 'ticket') {
|
|
750
|
+
lines.push(...nest(ticketLines({ records, item }), ' '))
|
|
751
|
+
} else if (item.what === 'final_review') {
|
|
752
|
+
lines.push(...nest(finalReviewLines({ records }), ' '))
|
|
753
|
+
}
|
|
754
|
+
const replies = replyLines({ records, since: item.seq })
|
|
755
|
+
lines.push(
|
|
756
|
+
replies.length === 0
|
|
757
|
+
? ' Replies since: none yet'
|
|
758
|
+
: ' Since then:',
|
|
759
|
+
...nest(replies, ' ')
|
|
760
|
+
)
|
|
761
|
+
}
|
|
762
|
+
return lines.join('\n')
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** One run of a repo, for picking the one that needs you. */
|
|
766
|
+
export type RunChoice = {
|
|
767
|
+
run_id: string
|
|
768
|
+
started: string
|
|
769
|
+
spec: number | null
|
|
770
|
+
over: boolean
|
|
771
|
+
stuck: number
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* The runs of `repo` in `runs_dir`, newest first, with how many things
|
|
776
|
+
* wait on a reply in each. A run's repo is its `run_started` repo; it
|
|
777
|
+
* matches any of `repos`, symlinks followed.
|
|
778
|
+
*/
|
|
779
|
+
export const repoRuns = ({
|
|
780
|
+
runs_dir,
|
|
781
|
+
repos,
|
|
782
|
+
}: {
|
|
783
|
+
runs_dir: string
|
|
784
|
+
repos: string[]
|
|
785
|
+
}): RunChoice[] => {
|
|
786
|
+
if (!existsSync(runs_dir)) return []
|
|
787
|
+
const want = new Set(repos.map(realPath))
|
|
788
|
+
return readdirSync(runs_dir)
|
|
789
|
+
.map((run_id) => ({
|
|
790
|
+
run_id,
|
|
791
|
+
records: readRecords({
|
|
792
|
+
file: join(runs_dir, run_id, 'journal.jsonl'),
|
|
793
|
+
}),
|
|
794
|
+
}))
|
|
795
|
+
.filter(({ records }) => {
|
|
796
|
+
const started = records.find((r) => r.kind === 'run_started')
|
|
797
|
+
return (
|
|
798
|
+
started !== undefined &&
|
|
799
|
+
want.has(realPath(text(started.content.repo) || '/'))
|
|
800
|
+
)
|
|
801
|
+
})
|
|
802
|
+
.map(({ run_id, records }) => ({
|
|
803
|
+
run_id,
|
|
804
|
+
started: records[0]?.time ?? '',
|
|
805
|
+
spec: numberOr(
|
|
806
|
+
records.find((r) => r.kind === 'run_started')?.content
|
|
807
|
+
.spec_number
|
|
808
|
+
),
|
|
809
|
+
over: runState({ records }).over,
|
|
810
|
+
stuck: openStuck({ records }).length,
|
|
811
|
+
}))
|
|
812
|
+
.toSorted((a, b) => b.started.localeCompare(a.started))
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** The flags, read. */
|
|
816
|
+
export type SummaryArgs = {
|
|
817
|
+
run_id: string | null
|
|
818
|
+
ticket: number | null
|
|
819
|
+
repo: string | null
|
|
820
|
+
runs_dir: string
|
|
821
|
+
registry: string
|
|
822
|
+
/** `--watch <seq>`: wait for the engine to take a reply posted after it. */
|
|
823
|
+
watch: number | null
|
|
824
|
+
/** How long `--watch` waits, in seconds. */
|
|
825
|
+
timeout_s: number
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** How long `--watch` waits by default: the engine reads replies every minute. */
|
|
829
|
+
const WATCH_TIMEOUT_S = 180
|
|
830
|
+
|
|
831
|
+
/** How often `--watch` reads the journal again. */
|
|
832
|
+
const WATCH_POLL_MS = 5000
|
|
833
|
+
|
|
834
|
+
/** Reads the flags; `env` gives the default folders. Pure. */
|
|
835
|
+
export const parseArgs = ({
|
|
836
|
+
argv,
|
|
837
|
+
env,
|
|
838
|
+
home,
|
|
839
|
+
}: {
|
|
840
|
+
argv: string[]
|
|
841
|
+
env: Record<string, string | undefined>
|
|
842
|
+
home: string
|
|
843
|
+
}): SummaryArgs => {
|
|
844
|
+
const args: SummaryArgs = {
|
|
845
|
+
run_id: null,
|
|
846
|
+
ticket: null,
|
|
847
|
+
repo: null,
|
|
848
|
+
runs_dir:
|
|
849
|
+
env.LUCA_RUNS_DIR ?? join(home, '.local', 'state', 'luca', 'runs'),
|
|
850
|
+
registry: join(
|
|
851
|
+
env.LUCA_BOARD_STATE_DIR || join(home, '.local/state/luca/board'),
|
|
852
|
+
'runs.json'
|
|
853
|
+
),
|
|
854
|
+
watch: null,
|
|
855
|
+
timeout_s: WATCH_TIMEOUT_S,
|
|
856
|
+
}
|
|
857
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
858
|
+
const arg = argv[i] ?? ''
|
|
859
|
+
const value = argv[i + 1]
|
|
860
|
+
if (arg === '--repo' && value !== undefined) {
|
|
861
|
+
args.repo = value
|
|
862
|
+
i += 1
|
|
863
|
+
} else if (arg === '--runs-dir' && value !== undefined) {
|
|
864
|
+
args.runs_dir = value
|
|
865
|
+
i += 1
|
|
866
|
+
} else if (arg === '--registry' && value !== undefined) {
|
|
867
|
+
args.registry = value
|
|
868
|
+
i += 1
|
|
869
|
+
} else if (arg === '--watch' && value !== undefined) {
|
|
870
|
+
args.watch = Number(value)
|
|
871
|
+
i += 1
|
|
872
|
+
} else if (arg === '--timeout-s' && value !== undefined) {
|
|
873
|
+
args.timeout_s = Number(value)
|
|
874
|
+
i += 1
|
|
875
|
+
} else if (/^#?\d+$/.test(arg)) {
|
|
876
|
+
args.ticket = Number(arg.replace('#', ''))
|
|
877
|
+
} else if (arg !== '' && !arg.startsWith('-')) {
|
|
878
|
+
args.run_id = arg
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return args
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* How the engine took a reply posted after record `after`: `done` once it
|
|
886
|
+
* took it and acted (a retry, a skip, a ship, a stop, or an answer that it
|
|
887
|
+
* can't use), `taken` once it only took it, `waiting` before that. `lines`
|
|
888
|
+
* are the records that tell. Pure.
|
|
889
|
+
*/
|
|
890
|
+
export const replyOutcome = ({
|
|
891
|
+
records,
|
|
892
|
+
after,
|
|
893
|
+
}: {
|
|
894
|
+
records: LooseRecord[]
|
|
895
|
+
after: number
|
|
896
|
+
}): { status: 'waiting' | 'taken' | 'done'; lines: string[] } => {
|
|
897
|
+
const since = records.filter((record) => record.seq > after)
|
|
898
|
+
const lines = replyLines({ records: since, since: after })
|
|
899
|
+
const acted = since.find((record) =>
|
|
900
|
+
[
|
|
901
|
+
'reply_ignored',
|
|
902
|
+
'ticket_retried',
|
|
903
|
+
'ticket_skipped',
|
|
904
|
+
'final_review_retried',
|
|
905
|
+
'final_review_shipped',
|
|
906
|
+
].includes(record.kind)
|
|
907
|
+
)
|
|
908
|
+
const taken = since.find((record) => record.kind === 'reply_received')
|
|
909
|
+
const run_level =
|
|
910
|
+
taken !== undefined &&
|
|
911
|
+
(taken.content.word === 'stop' ||
|
|
912
|
+
(taken.content.word === 'retry' && taken.content.ticket === null))
|
|
913
|
+
return {
|
|
914
|
+
status:
|
|
915
|
+
acted !== undefined || run_level
|
|
916
|
+
? 'done'
|
|
917
|
+
: taken !== undefined
|
|
918
|
+
? 'taken'
|
|
919
|
+
: 'waiting',
|
|
920
|
+
lines,
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Reads the journal every few seconds until the engine took the reply and
|
|
926
|
+
* acted, or `timeout_s` passed. Returns the exit code: 0 when done.
|
|
927
|
+
*/
|
|
928
|
+
const watchReply = async ({
|
|
929
|
+
file,
|
|
930
|
+
after,
|
|
931
|
+
timeout_s,
|
|
932
|
+
}: {
|
|
933
|
+
file: string
|
|
934
|
+
after: number
|
|
935
|
+
timeout_s: number
|
|
936
|
+
}): Promise<number> => {
|
|
937
|
+
const until = Date.now() + timeout_s * 1000
|
|
938
|
+
for (;;) {
|
|
939
|
+
const outcome = replyOutcome({ records: readRecords({ file }), after })
|
|
940
|
+
if (outcome.status === 'done' || Date.now() >= until) {
|
|
941
|
+
console.log(
|
|
942
|
+
outcome.status === 'done'
|
|
943
|
+
? 'The engine took the reply:'
|
|
944
|
+
: outcome.status === 'taken'
|
|
945
|
+
? `The engine took the reply, but hasn't acted on it yet after ${timeout_s} s:`
|
|
946
|
+
: `The engine hasn't taken a reply after ${timeout_s} s.`
|
|
947
|
+
)
|
|
948
|
+
for (const line of outcome.lines) console.log(` ${line}`)
|
|
949
|
+
return outcome.status === 'done' ? 0 : 1
|
|
950
|
+
}
|
|
951
|
+
await Bun.sleep(WATCH_POLL_MS)
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* A path with its symlinks followed (`/tmp` is `/private/tmp` on macOS), or
|
|
957
|
+
* just made absolute when it doesn't exist.
|
|
958
|
+
*/
|
|
959
|
+
const realPath = (path: string): string => {
|
|
960
|
+
try {
|
|
961
|
+
return realpathSync(path)
|
|
962
|
+
} catch {
|
|
963
|
+
return resolve(path)
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* The repos a run started from `cwd` may name, real paths, main first: the
|
|
969
|
+
* main checkout (a run's repo is the main checkout), then the git top folder
|
|
970
|
+
* (a worktree's own, or a subfolder's repo). Outside git, `cwd` itself. A
|
|
971
|
+
* bare repo or a submodule has no `<checkout>/.git` common dir, so it adds
|
|
972
|
+
* no main checkout, only its top folder.
|
|
973
|
+
*
|
|
974
|
+
* Its twin is `currentRepos` in `luca-retro/scripts/retro-summary.ts`: each skill folder is installed
|
|
975
|
+
* to `~/.claude/skills/` on its own, so they can't share a file. Keep the two
|
|
976
|
+
* the same; a test in each pins it.
|
|
977
|
+
*
|
|
978
|
+
* @example
|
|
979
|
+
* currentRepos({ cwd: '/code/app-worktree' }) // ['/code/app', '/code/app-worktree']
|
|
980
|
+
*/
|
|
981
|
+
export const currentRepos = ({ cwd }: { cwd: string }): string[] => {
|
|
982
|
+
const git = (...args: string[]) => {
|
|
983
|
+
const out = Bun.spawnSync(['git', '-C', cwd, ...args])
|
|
984
|
+
const said = out.stdout.toString().trim()
|
|
985
|
+
return out.exitCode === 0 && said !== '' ? said : null
|
|
986
|
+
}
|
|
987
|
+
const top = git('rev-parse', '--show-toplevel')
|
|
988
|
+
const common = git(
|
|
989
|
+
'rev-parse',
|
|
990
|
+
'--path-format=absolute',
|
|
991
|
+
'--git-common-dir'
|
|
992
|
+
)
|
|
993
|
+
const main =
|
|
994
|
+
common !== null && basename(common) === '.git' ? dirname(common) : null
|
|
995
|
+
return [main, top ?? cwd]
|
|
996
|
+
.filter((repo) => repo !== null)
|
|
997
|
+
.map(realPath)
|
|
998
|
+
.filter((repo, index, all) => all.indexOf(repo) === index)
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const main = async () => {
|
|
1002
|
+
const args = parseArgs({
|
|
1003
|
+
argv: Bun.argv.slice(2),
|
|
1004
|
+
env: process.env,
|
|
1005
|
+
home: homedir(),
|
|
1006
|
+
})
|
|
1007
|
+
if (args.watch !== null) {
|
|
1008
|
+
if (args.run_id === null || Number.isNaN(args.watch)) {
|
|
1009
|
+
console.error('Usage: stuck-summary.ts <run id> --watch <seq>')
|
|
1010
|
+
process.exit(2)
|
|
1011
|
+
}
|
|
1012
|
+
process.exit(
|
|
1013
|
+
await watchReply({
|
|
1014
|
+
file: join(args.runs_dir, args.run_id, 'journal.jsonl'),
|
|
1015
|
+
after: args.watch,
|
|
1016
|
+
timeout_s: args.timeout_s,
|
|
1017
|
+
})
|
|
1018
|
+
)
|
|
1019
|
+
}
|
|
1020
|
+
let run_id = args.run_id
|
|
1021
|
+
if (run_id === null) {
|
|
1022
|
+
const repos =
|
|
1023
|
+
args.repo === null
|
|
1024
|
+
? currentRepos({ cwd: process.cwd() })
|
|
1025
|
+
: [args.repo]
|
|
1026
|
+
const runs = repoRuns({ runs_dir: args.runs_dir, repos })
|
|
1027
|
+
if (runs.length === 0) {
|
|
1028
|
+
console.log(
|
|
1029
|
+
`No Luca runs of ${repos.join(' or ')} in ${args.runs_dir}.`
|
|
1030
|
+
)
|
|
1031
|
+
return
|
|
1032
|
+
}
|
|
1033
|
+
console.log(`Runs of ${repos[0]}, newest first:`)
|
|
1034
|
+
for (const run of runs.slice(0, 10)) {
|
|
1035
|
+
console.log(
|
|
1036
|
+
`- ${run.run_id}: spec #${run.spec ?? '?'}, started ${run.started}, ${run.over ? 'over' : 'not over'}, ${run.stuck} waiting on a reply`
|
|
1037
|
+
)
|
|
1038
|
+
}
|
|
1039
|
+
const pick =
|
|
1040
|
+
runs.find((run) => run.stuck > 0 && !run.over) ??
|
|
1041
|
+
runs.find((run) => !run.over) ??
|
|
1042
|
+
runs[0]
|
|
1043
|
+
if (pick === undefined) return
|
|
1044
|
+
run_id = pick.run_id
|
|
1045
|
+
console.log(`\nThe one that needs you most: ${run_id}\n`)
|
|
1046
|
+
}
|
|
1047
|
+
const records = readRecords({
|
|
1048
|
+
file: join(args.runs_dir, run_id, 'journal.jsonl'),
|
|
1049
|
+
})
|
|
1050
|
+
const registry = registryEntry({ registry: args.registry, run_id })
|
|
1051
|
+
console.log(
|
|
1052
|
+
formatSummary({
|
|
1053
|
+
run_id,
|
|
1054
|
+
records,
|
|
1055
|
+
ticket: args.ticket,
|
|
1056
|
+
registry,
|
|
1057
|
+
running: engineRunning({ run_id, command_lines: commandLines() }),
|
|
1058
|
+
log_tail: logTail(registry?.log_path ?? null),
|
|
1059
|
+
})
|
|
1060
|
+
)
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (import.meta.main) await main()
|