catherd-cli 1.4.0 → 2.0.0-beta.20261022113309
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/README.md +188 -169
- package/THIRD_PARTY_NOTICES.md +217 -35
- package/catalog/jev.json +27 -1
- package/catalog/models.json +56 -239
- package/package.json +6 -8
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +97 -356
- package/plugin/skills/catherd-setup/SKILL.md +21 -20
- package/src/adapters/all.ts +0 -4
- package/src/adapters/backend.ts +19 -4
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/home.ts +1 -1
- package/src/adapters/cursor/index.ts +10 -4
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +27 -27
- package/src/domain/agents.ts +3 -1
- package/src/domain/bars.ts +33 -107
- package/src/domain/blocked.ts +72 -0
- package/src/domain/branch.ts +51 -0
- package/src/domain/brief.ts +106 -0
- package/src/domain/catalog.ts +69 -313
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +19 -29
- package/src/domain/errors.ts +6 -2
- package/src/domain/fail-lines.ts +26 -0
- package/src/domain/failover.ts +37 -75
- package/src/domain/findings.ts +47 -0
- package/src/domain/gate-brief.ts +53 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/ids.ts +13 -2
- package/src/domain/jev.ts +47 -2
- package/src/domain/judge.ts +124 -0
- package/src/domain/lane.ts +118 -3
- package/src/domain/milestone-memory.ts +53 -0
- package/src/domain/notice.ts +32 -8
- package/src/domain/notify.ts +32 -0
- package/src/domain/plan-doc.ts +198 -0
- package/src/domain/plan-lint.ts +141 -0
- package/src/domain/profile-rules.ts +175 -138
- package/src/domain/profile.ts +140 -63
- package/src/domain/record.ts +68 -3
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +182 -56
- package/src/domain/role-scope.ts +76 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/roles.ts +2 -0
- package/src/domain/route.ts +90 -0
- package/src/domain/rules.ts +33 -0
- package/src/domain/run-events.ts +298 -0
- package/src/domain/run-machine.ts +668 -0
- package/src/domain/run-state.ts +1310 -0
- package/src/domain/scorecard.ts +197 -0
- package/src/domain/select.ts +229 -72
- package/src/domain/slopalytics.ts +72 -0
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/answer-command.ts +39 -0
- package/src/entry/catalog-command.ts +24 -165
- package/src/entry/check-bin.ts +17 -0
- package/src/entry/cli-kit.ts +18 -2
- package/src/entry/daemon-bin.ts +22 -0
- package/src/entry/deps.ts +2 -0
- package/src/entry/doctor-command.ts +20 -2
- package/src/entry/glyphs.ts +15 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +20 -69
- package/src/entry/knowledge-command.ts +121 -5
- package/src/entry/lock-command.ts +103 -8
- package/src/entry/mcp/handshake.ts +6 -2
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +134 -0
- package/src/entry/mcp/server.ts +85 -21
- package/src/entry/mcp/session-tools.ts +149 -0
- package/src/entry/panel/openapi.ts +189 -0
- package/src/entry/panel/server.ts +410 -0
- package/src/entry/panel-command.ts +68 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +33 -18
- package/src/entry/role-cli-command.ts +159 -0
- package/src/entry/runs-command.ts +81 -27
- package/src/entry/scorecard-command.ts +187 -0
- package/src/entry/supervise-bin.ts +1 -1
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/delivery.ts +3 -0
- package/src/infra/dispatch-dir.ts +2 -0
- package/src/infra/env.ts +11 -3
- package/src/infra/git.ts +70 -0
- package/src/infra/heavy-lock.ts +133 -6
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/launch.ts +63 -7
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/memory.ts +45 -0
- package/src/infra/ntfy.ts +40 -0
- package/src/infra/open-url.ts +42 -0
- package/src/infra/paths.ts +37 -2
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/slopalytics.ts +30 -0
- package/src/infra/store.ts +6 -1
- package/src/infra/supervisor.ts +107 -16
- package/src/services/admission.ts +133 -31
- package/src/services/backends.ts +4 -6
- package/src/services/budget.ts +26 -6
- package/src/services/capture.ts +1 -22
- package/src/services/catalog-service.ts +155 -254
- package/src/services/credentials.ts +15 -26
- package/src/services/daemon.ts +181 -0
- package/src/services/dispatch-service.ts +209 -17
- package/src/services/dispatches.ts +30 -2
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +1 -1
- package/src/services/doctor-checks.ts +47 -0
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +72 -15
- package/src/services/end-push.ts +157 -0
- package/src/services/failover-rungs.ts +109 -0
- package/src/services/finalize.ts +24 -2
- package/src/services/gate-env.ts +217 -0
- package/src/services/gate-service.ts +231 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/jev-service.ts +2 -4
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +291 -55
- package/src/services/milestone-check.ts +433 -0
- package/src/services/milestone-tree.ts +170 -0
- package/src/services/milestones.ts +167 -29
- package/src/services/notifier.ts +124 -4
- package/src/services/notify-service.ts +26 -0
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/panel-setup.ts +116 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +79 -17
- package/src/services/plan-input.ts +151 -0
- package/src/services/ports.ts +60 -5
- package/src/services/preflight.ts +232 -24
- package/src/services/profile-service.ts +31 -3
- package/src/services/profile-store.ts +18 -6
- package/src/services/protocol.ts +123 -25
- package/src/services/provenance.ts +34 -89
- package/src/services/questions.ts +24 -0
- package/src/services/reconcile.ts +33 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +161 -42
- package/src/services/run-briefs.ts +477 -0
- package/src/services/run-client.ts +217 -0
- package/src/services/run-effects.ts +837 -0
- package/src/services/run-events-store.ts +174 -0
- package/src/services/run-evidence.ts +1 -1
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-read.ts +221 -0
- package/src/services/run-report.ts +176 -0
- package/src/services/run-service.ts +204 -23
- package/src/services/run-store.ts +159 -18
- package/src/services/scorecard-metrics.ts +86 -0
- package/src/services/scorecard.ts +382 -0
- package/src/services/scratch.ts +60 -0
- package/src/services/sessions.ts +3 -1
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +76 -12
- package/src/services/tree-path.ts +27 -0
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +259 -0
- package/src/services/workspace-service.ts +259 -0
- package/src/services/workspace-store.ts +95 -0
- package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js +1 -0
- package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
- package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js +1 -0
- package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/index-B6GiwDkh.js +11 -0
- package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
- package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
- package/web/dist/assets/index-C5IbylvM.css +2 -0
- package/web/dist/assets/index-C5IbylvM.css.br +0 -0
- package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
- package/web/dist/assets/label-DFSYBc62.js +1 -0
- package/web/dist/assets/label-DFSYBc62.js.br +0 -0
- package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
- package/web/dist/assets/link-D8EcKZUt.js +1 -0
- package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
- package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
- package/web/dist/assets/login-DaN5HPPW.js +1 -0
- package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
- package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
- package/web/dist/assets/query-Cu--ccT3.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
- package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
- package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
- package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
- package/web/dist/assets/table-BOxXhqS4.js +1 -0
- package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
- package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
- package/web/dist/assets/time--9kSQbbp.js +1 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
- package/web/dist/index.html +15 -0
- package/catalog/ATTRIBUTION.md +0 -36
- package/catalog/scores.json +0 -3202
- package/catalog/sources.json +0 -97
- package/src/adapters/antigravity/events.ts +0 -131
- package/src/adapters/antigravity/home.ts +0 -46
- package/src/adapters/antigravity/index.ts +0 -320
- package/src/adapters/antigravity/models.ts +0 -37
- package/src/adapters/grok/events.ts +0 -112
- package/src/adapters/grok/home.ts +0 -132
- package/src/adapters/grok/index.ts +0 -339
- package/src/adapters/grok/models.ts +0 -48
- package/src/domain/calibration.ts +0 -125
- package/src/domain/sources.ts +0 -144
- package/src/entry/mcp/dispatch-tools.ts +0 -52
- package/src/entry/mcp/lane-tools.ts +0 -84
- package/src/entry/mcp/protocol-tools.ts +0 -57
- package/src/entry/mcp/run-tools.ts +0 -148
- package/src/entry/mcp/setup-tools.ts +0 -108
- package/src/entry/tui/commands.ts +0 -563
- package/src/entry/tui/effects.ts +0 -381
- package/src/entry/tui/fixtures.ts +0 -637
- package/src/entry/tui/profile-edits.ts +0 -146
- package/src/entry/tui/profile-tree.ts +0 -429
- package/src/entry/tui/providers/app.tsx +0 -162
- package/src/entry/tui/providers/boundary.tsx +0 -57
- package/src/entry/tui/providers/data.tsx +0 -160
- package/src/entry/tui/providers/keymap.tsx +0 -166
- package/src/entry/tui/providers/root.tsx +0 -45
- package/src/entry/tui/providers/theme.tsx +0 -17
- package/src/entry/tui/providers/toast.tsx +0 -77
- package/src/entry/tui/run.tsx +0 -114
- package/src/entry/tui/state.ts +0 -373
- package/src/entry/tui/stories.ts +0 -114
- package/src/entry/tui/text.ts +0 -130
- package/src/entry/tui/theme.ts +0 -140
- package/src/entry/tui/views/app.tsx +0 -208
- package/src/entry/tui/views/dialogs.tsx +0 -41
- package/src/entry/tui/views/profile-actions.ts +0 -331
- package/src/entry/tui/views/profiles.tsx +0 -520
- package/src/entry/tui/views/runs.tsx +0 -573
- package/src/entry/tui/views/save-dialog.tsx +0 -340
- package/src/entry/tui/views/status.tsx +0 -209
- package/src/entry/tui/widgets/chrome.tsx +0 -114
- package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
- package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
- package/src/entry/tui/widgets/dialog-select.tsx +0 -212
- package/src/entry/tui/widgets/dialog.tsx +0 -68
- package/src/entry/tui/widgets/line.tsx +0 -92
- package/src/entry/tui/widgets/list.tsx +0 -197
- package/src/entry/tui/widgets/spinner.tsx +0 -49
- package/src/entry/tui/widgets/toast.tsx +0 -62
- package/src/infra/sources/arena.ts +0 -87
- package/src/infra/sources/artificial-analysis.ts +0 -112
- package/src/infra/sources/cache.ts +0 -124
- package/src/infra/sources/csv.ts +0 -38
- package/src/infra/sources/epoch.ts +0 -82
- package/src/infra/sources/http.ts +0 -75
- package/src/infra/sources/litellm.ts +0 -58
- package/src/infra/sources/models-dev.ts +0 -59
- package/src/infra/sources/openrouter-endpoints.ts +0 -52
- package/src/infra/sources/openrouter-models.ts +0 -60
- package/src/infra/sources/rows.ts +0 -38
- package/src/infra/sources/vectara.ts +0 -69
- package/src/infra/sources/zip.ts +0 -48
- package/src/services/catalog-refresh.ts +0 -201
- package/src/services/doctor-sources.ts +0 -76
- package/src/services/runs-page.ts +0 -255
- package/src/services/source-derive.ts +0 -374
- package/src/services/source-sync.ts +0 -305
- package/src/services/standins.ts +0 -204
- package/src/services/treat-likes.ts +0 -97
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { CatherdError } from "../domain/errors.ts";
|
|
4
4
|
import type { RunRecord } from "../domain/record.ts";
|
|
5
|
-
import { git } from "../infra/git.ts";
|
|
5
|
+
import { commitExists, git } from "../infra/git.ts";
|
|
6
6
|
import { nonBlankLines } from "../infra/store.ts";
|
|
7
7
|
import { listDispatches } from "./dispatches.ts";
|
|
8
8
|
import { readAgentRuns, readRecords, readRoutes, type Run, runPaths } from "./run-store.ts";
|
|
@@ -50,27 +50,99 @@ export interface MilestoneReviewer {
|
|
|
50
50
|
record: RunRecord | null;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
* ok; or a record_agent_run row with role reviewer and the same name, status ok. Null when there is none.
|
|
56
|
-
*/
|
|
57
|
-
export function milestoneReviewer(
|
|
53
|
+
/** Every reviewer attempt of the milestone since its lanes started that ended ok, oldest first, partial or not. */
|
|
54
|
+
function reviewerAttempts(
|
|
58
55
|
run: Run,
|
|
59
56
|
m: string,
|
|
60
|
-
start
|
|
61
|
-
): MilestoneReviewer
|
|
62
|
-
|
|
57
|
+
start: string | null,
|
|
58
|
+
): (MilestoneReviewer & { partial: boolean })[] {
|
|
59
|
+
return [
|
|
63
60
|
...readRecords(run)
|
|
64
61
|
.records.filter((r) => reviewsMilestone(r.name, m) && r.status === "ok" && since(r.endedAt, start))
|
|
65
|
-
.map((r) => ({ name: r.name, at: r.endedAt, record: r })),
|
|
62
|
+
.map((r) => ({ name: r.name, at: r.endedAt, record: r, partial: r.replyStatus === "partial" })),
|
|
66
63
|
...readAgentRuns(run)
|
|
67
64
|
.filter(
|
|
68
65
|
(a) =>
|
|
69
66
|
a.role === "reviewer" && reviewsMilestone(a.name, m) && a.status === "ok" && since(a.at, start),
|
|
70
67
|
)
|
|
71
|
-
.map((a) => ({ name: a.name, at: a.at, record: null })),
|
|
72
|
-
];
|
|
73
|
-
|
|
68
|
+
.map((a) => ({ name: a.name, at: a.at, record: null, partial: a.replyStatus === "partial" })),
|
|
69
|
+
].sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The milestone's latest passing reviewer since its lanes started: a dispatch named reviewer-<m>…, status
|
|
74
|
+
* ok; or a record_agent_run row with role reviewer and the same name, status ok. A reply of STATUS: partial
|
|
75
|
+
* read only part of the diff and is not the milestone's review (plan 23). Null when there is none.
|
|
76
|
+
*/
|
|
77
|
+
export function milestoneReviewer(
|
|
78
|
+
run: Run,
|
|
79
|
+
m: string,
|
|
80
|
+
start = milestoneStart(run, m),
|
|
81
|
+
): MilestoneReviewer | null {
|
|
82
|
+
const full = reviewerAttempts(run, m, start).filter((a) => !a.partial);
|
|
83
|
+
const last = full.at(-1);
|
|
84
|
+
return last ? { name: last.name, at: last.at, record: last.record } : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Plan 23: the milestone's latest reviewer that stopped at STATUS: partial, when no full review stands; else null. */
|
|
88
|
+
export function partialReviewer(
|
|
89
|
+
run: Run,
|
|
90
|
+
m: string,
|
|
91
|
+
start = milestoneStart(run, m),
|
|
92
|
+
): MilestoneReviewer | null {
|
|
93
|
+
if (milestoneReviewer(run, m, start)) return null;
|
|
94
|
+
const last = reviewerAttempts(run, m, start)
|
|
95
|
+
.filter((a) => a.partial)
|
|
96
|
+
.at(-1);
|
|
97
|
+
return last ? { name: last.name, at: last.at, record: last.record } : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Plan 28: the milestone's full reviewer passes since its lanes started (a partial pass is not a round). */
|
|
101
|
+
export const reviewRounds = (run: Run, m: string): number =>
|
|
102
|
+
reviewerAttempts(run, m, milestoneStart(run, m)).filter((a) => !a.partial).length;
|
|
103
|
+
|
|
104
|
+
export const FINDING = /^\s*(?:[-*]\s*)?(BLOCKER|BUG|NIT)\b/;
|
|
105
|
+
|
|
106
|
+
/** A reviewer reply's findings by severity; null when there is no reply to read. */
|
|
107
|
+
export function countFindings(run: Run, r: RunRecord): { BLOCKER: number; BUG: number; NIT: number } | null {
|
|
108
|
+
const file = join(run.dir, r.replyPath);
|
|
109
|
+
if (!r.replyPath || !existsSync(file)) return null;
|
|
110
|
+
const n = { BLOCKER: 0, BUG: 0, NIT: 0 };
|
|
111
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
112
|
+
const k = FINDING.exec(line)?.[1] as keyof typeof n | undefined;
|
|
113
|
+
if (k) n[k]++;
|
|
114
|
+
}
|
|
115
|
+
return n;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The roles a fix round runs as. */
|
|
119
|
+
const FIX_ROLES = new Set(["worker", "writer", "artist"]);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Plan 23: the milestone reviewer's BLOCKER and BUG lines while no fix round has ended since (a worker,
|
|
123
|
+
* writer or artist of the milestone, ended ok after the review); null when there are none, or for a native
|
|
124
|
+
* reviewer, whose reply catherd does not keep.
|
|
125
|
+
*/
|
|
126
|
+
export function openFindings(
|
|
127
|
+
run: Run,
|
|
128
|
+
m: string,
|
|
129
|
+
start = milestoneStart(run, m),
|
|
130
|
+
): { name: string; blocker: number; bug: number } | null {
|
|
131
|
+
const reviewer = milestoneReviewer(run, m, start);
|
|
132
|
+
if (!reviewer?.record) return null;
|
|
133
|
+
const n = countFindings(run, reviewer.record);
|
|
134
|
+
if (!n || n.BLOCKER + n.BUG === 0) return null;
|
|
135
|
+
const after = (at: string) => Date.parse(at) > Date.parse(reviewer.at);
|
|
136
|
+
const ofM = (name: string, lane: string | null | undefined) =>
|
|
137
|
+
inMilestone(lane, m) || namesMilestone(name, m);
|
|
138
|
+
const fixed =
|
|
139
|
+
readRecords(run).records.some(
|
|
140
|
+
(r) => FIX_ROLES.has(r.role) && r.status === "ok" && ofM(r.name, r.lane) && after(r.endedAt),
|
|
141
|
+
) ||
|
|
142
|
+
readAgentRuns(run).some(
|
|
143
|
+
(a) => FIX_ROLES.has(a.role) && a.status === "ok" && ofM(a.name, a.lane) && after(a.at),
|
|
144
|
+
);
|
|
145
|
+
return fixed ? null : { name: reviewer.name, blocker: n.BLOCKER, bug: n.BUG };
|
|
74
146
|
}
|
|
75
147
|
|
|
76
148
|
/** A reviewer record for the milestone since its lanes started (see milestoneReviewer). */
|
|
@@ -80,6 +152,15 @@ export const reviewerPassed = (run: Run, m: string, start = milestoneStart(run,
|
|
|
80
152
|
/** The verifier's contract puts `VERDICT: PASS` or `VERDICT: FAIL` on the reply's first line (role-prompts.ts). */
|
|
81
153
|
const VERDICT_PASS = /^VERDICT: PASS\b/;
|
|
82
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Plan 23: `VERDICT: BLOCKED: environment — <the probe that proves it>`, a verdict on the machine, not on the
|
|
157
|
+
* work. Its probe, or null when `line` is no such verdict ("" when it names none).
|
|
158
|
+
*/
|
|
159
|
+
export function blockedByEnvironment(line: string | null | undefined): string | null {
|
|
160
|
+
const m = /^VERDICT:\s*BLOCKED:\s*environment\b\s*(?:—|–|-|:)?\s*(.*)$/i.exec(line?.trim() ?? "");
|
|
161
|
+
return m ? (m[1] ?? "").trim() : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
83
164
|
/** A headless verifier's reply: its first non-blank line, trimmed; null when there is no reply to read. */
|
|
84
165
|
export function replyVerdict(run: Run, r: RunRecord): string | null {
|
|
85
166
|
const file = join(run.dir, r.replyPath);
|
|
@@ -106,11 +187,19 @@ export interface MilestoneVerifier {
|
|
|
106
187
|
passed: boolean;
|
|
107
188
|
/** what the attempt said: a native row's status, or a headless reply's first non-blank line ("no reply") */
|
|
108
189
|
verdict: string;
|
|
190
|
+
/** plan 23: the probe of a `VERDICT: BLOCKED: environment` attempt ("" when it named none); null otherwise */
|
|
191
|
+
blocked: string | null;
|
|
192
|
+
/** plan 30b: the commit the verdict says it checked (a headless reply's `HEAD:` line); null for a native row */
|
|
193
|
+
head: string | null;
|
|
109
194
|
}
|
|
110
195
|
|
|
196
|
+
/** The one name a milestone's verifier is counted under (spec 1.5 "The ledger"): verifier-<m>, exactly. */
|
|
197
|
+
export const verifierName = (m: string): string => `verifier-${m}`;
|
|
198
|
+
|
|
111
199
|
/**
|
|
112
200
|
* The milestone's latest verifier attempt since its lanes started, whatever it said: a record_agent_run row
|
|
113
|
-
* with role verifier
|
|
201
|
+
* with role verifier named exactly verifier-<m> (the skill records a FAIL as failed; verifier-M2-pre or
|
|
202
|
+
* verifier-M2-gate1 is not M2's verifier), passing when its
|
|
114
203
|
* status is ok; or a headless verifier's dispatch record of the same shape, passing when it exited ok and its
|
|
115
204
|
* reply opens VERDICT: PASS (status ok means only the CLI exited). A later FAIL undoes an earlier PASS. Null
|
|
116
205
|
* when there is none.
|
|
@@ -122,16 +211,22 @@ export function milestoneVerifier(
|
|
|
122
211
|
): MilestoneVerifier | null {
|
|
123
212
|
const found: MilestoneVerifier[] = [
|
|
124
213
|
...readAgentRuns(run)
|
|
125
|
-
.filter((a) => a.role === "verifier" &&
|
|
126
|
-
.map((a) =>
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
214
|
+
.filter((a) => a.role === "verifier" && a.name === verifierName(m) && since(a.at, start))
|
|
215
|
+
.map((a) => {
|
|
216
|
+
// plan 23: a native verifier's verdict line, when record_agent_run passed it
|
|
217
|
+
const blocked = a.status === "ok" ? null : blockedByEnvironment(a.verdict);
|
|
218
|
+
return {
|
|
219
|
+
name: a.name,
|
|
220
|
+
at: a.at,
|
|
221
|
+
headless: false,
|
|
222
|
+
passed: a.status === "ok",
|
|
223
|
+
verdict: blocked === null ? a.status : (a.verdict ?? a.status),
|
|
224
|
+
blocked,
|
|
225
|
+
head: null,
|
|
226
|
+
};
|
|
227
|
+
}),
|
|
133
228
|
...readRecords(run)
|
|
134
|
-
.records.filter((r) => r.role === "verifier" &&
|
|
229
|
+
.records.filter((r) => r.role === "verifier" && r.name === verifierName(m) && since(r.endedAt, start))
|
|
135
230
|
.map((r) => {
|
|
136
231
|
const first = replyVerdict(run, r);
|
|
137
232
|
const passed = r.status === "ok" && first !== null && VERDICT_PASS.test(first);
|
|
@@ -142,6 +237,8 @@ export function milestoneVerifier(
|
|
|
142
237
|
headless: true,
|
|
143
238
|
passed,
|
|
144
239
|
verdict: r.status === "ok" ? said : `${r.status}, ${said}`,
|
|
240
|
+
blocked: r.status === "ok" ? blockedByEnvironment(first) : null,
|
|
241
|
+
head: r.head ?? null,
|
|
145
242
|
};
|
|
146
243
|
}),
|
|
147
244
|
];
|
|
@@ -161,6 +258,16 @@ export function landedCommits(run: Run): string[] {
|
|
|
161
258
|
.filter((c) => /^[0-9a-f]{7,40}$/.test(c));
|
|
162
259
|
}
|
|
163
260
|
|
|
261
|
+
/** The commit the ledger holds for `m`, its latest landing; null when it never landed or the row has no commit. */
|
|
262
|
+
export function landedCommitOf(run: Run, m: string): string | null {
|
|
263
|
+
const rows = nonBlankLines(runPaths(run.dir).ledger)
|
|
264
|
+
.slice(1)
|
|
265
|
+
.map((row) => row.split(" | "))
|
|
266
|
+
.filter((cells) => cells[0]?.trim() === m);
|
|
267
|
+
const commit = rows.at(-1)?.[2]?.trim() ?? "";
|
|
268
|
+
return /^[0-9a-f]{7,40}$/.test(commit) ? commit : null;
|
|
269
|
+
}
|
|
270
|
+
|
|
164
271
|
/** The milestones the ledger holds, in landing order. */
|
|
165
272
|
export function landedMilestones(run: Run): string[] {
|
|
166
273
|
return nonBlankLines(runPaths(run.dir).ledger)
|
|
@@ -169,16 +276,29 @@ export function landedMilestones(run: Run): string[] {
|
|
|
169
276
|
.filter(Boolean);
|
|
170
277
|
}
|
|
171
278
|
|
|
279
|
+
/** The 1.5 range: see milestoneFiles. */
|
|
280
|
+
async function oneFiveFiles(run: Run, commit: string): ReturnType<typeof git> {
|
|
281
|
+
const repo = run.meta.repo;
|
|
282
|
+
const start = run.meta.startHead;
|
|
283
|
+
const base =
|
|
284
|
+
landedCommits(run).at(-1) ??
|
|
285
|
+
(start && (await commitExists(repo, start).catch(() => false)) ? start : undefined);
|
|
286
|
+
return base
|
|
287
|
+
? await git(repo, ["diff", "--name-only", base, commit])
|
|
288
|
+
: await git(repo, ["show", "--name-only", "--format=", "--first-parent", commit]);
|
|
289
|
+
}
|
|
290
|
+
|
|
172
291
|
/**
|
|
173
|
-
* The files the milestone's commit range changed: from the previous landed commit
|
|
174
|
-
*
|
|
292
|
+
* The files the milestone's commit range changed: from the previous landed commit, else the HEAD the run
|
|
293
|
+
* started on (meta.json `startHead`, when that commit is still there), else the commit's own parent, to
|
|
294
|
+
* `commit`. A base run's milestone is its branch since origin's base (plan 35). Throws E_IO_UNEXPECTED when git cannot say.
|
|
175
295
|
*/
|
|
176
296
|
export async function milestoneFiles(run: Run, commit: string): Promise<string[]> {
|
|
177
|
-
const base = landedCommits(run).at(-1);
|
|
178
297
|
const repo = run.meta.repo;
|
|
179
|
-
const r = base
|
|
180
|
-
?
|
|
181
|
-
|
|
298
|
+
const r = run.meta.base
|
|
299
|
+
? // plan 35: a base run's milestone is its branch since origin's base; three dots, since other milestones land on the base meanwhile
|
|
300
|
+
await git(repo, ["diff", "--name-only", `origin/${run.meta.base}...${commit}`])
|
|
301
|
+
: await oneFiveFiles(run, commit);
|
|
182
302
|
if (r.kind !== "ok")
|
|
183
303
|
throw new CatherdError("E_IO_UNEXPECTED", `git could not list the files ${commit} changed`, {
|
|
184
304
|
fix: `check that git works in ${repo}`,
|
|
@@ -186,6 +306,24 @@ export async function milestoneFiles(run: Run, commit: string): Promise<string[]
|
|
|
186
306
|
return r.out.split("\n").filter(Boolean);
|
|
187
307
|
}
|
|
188
308
|
|
|
309
|
+
/** The most commits a digest lists; the rest are counted. */
|
|
310
|
+
const DIGEST_COMMITS = 20;
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The commits the milestone lands, `<short hash> <subject>`, oldest first: from the previous landed commit
|
|
314
|
+
* (else the commit alone) to `commit`. Just the hash when git cannot say: a digest never fails a landing.
|
|
315
|
+
*/
|
|
316
|
+
export async function milestoneCommits(run: Run, commit: string): Promise<string[]> {
|
|
317
|
+
const base = run.meta.base ? `origin/${run.meta.base}` : landedCommits(run).at(-1);
|
|
318
|
+
const range = base ? [`${base}..${commit}`] : ["-1", commit];
|
|
319
|
+
const r = await git(run.meta.repo, ["log", "--reverse", "--format=%h %s", ...range]);
|
|
320
|
+
if (r.kind !== "ok") return [commit.slice(0, 7)];
|
|
321
|
+
const all = r.out.split("\n").filter(Boolean);
|
|
322
|
+
return all.length > DIGEST_COMMITS
|
|
323
|
+
? [...all.slice(-DIGEST_COMMITS), `and ${all.length - DIGEST_COMMITS} earlier`]
|
|
324
|
+
: all;
|
|
325
|
+
}
|
|
326
|
+
|
|
189
327
|
/** `rev`'s full commit hash in the run's repo. Throws E_IO_UNEXPECTED when git cannot say. */
|
|
190
328
|
export async function fullCommit(run: Run, rev: string): Promise<string> {
|
|
191
329
|
const r = await git(run.meta.repo, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]);
|
package/src/services/notifier.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { adapterFor } from "../adapters/registry.ts";
|
|
4
|
+
import "../adapters/all.ts";
|
|
3
5
|
import { CatherdError, errorMessage } from "../domain/errors.ts";
|
|
4
6
|
import { envelope, formatNotices, type Notice, type NoticePriority, priorityOf } from "../domain/notice.ts";
|
|
5
7
|
import { sessionKey, type HostSessionRef } from "../domain/host.ts";
|
|
@@ -7,15 +9,17 @@ import { sendToCodexQueue, type QueueSendResult } from "../infra/codex-queue.ts"
|
|
|
7
9
|
import {
|
|
8
10
|
deliveryState,
|
|
9
11
|
readDelivery,
|
|
12
|
+
ROLE_THREAD_REFUSAL,
|
|
10
13
|
writeDeliveryAttempt,
|
|
11
14
|
type DeliveryAttempt,
|
|
12
15
|
} from "../infra/delivery.ts";
|
|
13
16
|
import type { RunRecord } from "../domain/record.ts";
|
|
17
|
+
import type { RunEvent } from "../domain/run-events.ts";
|
|
14
18
|
import { awaitsCollect, dispatchPaths } from "../infra/dispatch-dir.ts";
|
|
15
19
|
import { tryLock } from "../infra/filelock.ts";
|
|
16
20
|
import { log } from "../infra/log.ts";
|
|
17
21
|
import { type SendResult, sendToInbox } from "../infra/peer-inbox.ts";
|
|
18
|
-
import { writeJsonAtomic } from "../infra/store.ts";
|
|
22
|
+
import { ensurePrivateDir, nonBlankLines, writeJsonAtomic } from "../infra/store.ts";
|
|
19
23
|
import {
|
|
20
24
|
readStallQuietMs,
|
|
21
25
|
type Settled,
|
|
@@ -23,8 +27,9 @@ import {
|
|
|
23
27
|
type Stalled,
|
|
24
28
|
stallHooks,
|
|
25
29
|
} from "./dispatch-service.ts";
|
|
26
|
-
import { type Dispatch, listDispatches, readFailover } from "./dispatches.ts";
|
|
30
|
+
import { type Dispatch, listDispatches, readFailover, roleThreadOf } from "./dispatches.ts";
|
|
27
31
|
import type { Deps } from "./ports.ts";
|
|
32
|
+
import { eventsFile, isDaemonRun, readEvents } from "./run-events-store.ts";
|
|
28
33
|
import { listRuns, readRecords, type Run } from "./run-store.ts";
|
|
29
34
|
import { currentSession, ownsRun, readSessionRows, runOwner } from "./sessions.ts";
|
|
30
35
|
|
|
@@ -32,6 +37,8 @@ export interface NotifierOptions {
|
|
|
32
37
|
/** notices this close to each other go as one message (spec: 3 s); tests shorten it */
|
|
33
38
|
coalesceMs?: number;
|
|
34
39
|
sendCodex?: (target: HostSessionRef, content: string) => Promise<QueueSendResult>;
|
|
40
|
+
/** how often the reports of the session's 2.0 runs are looked for between scans; 0 (the default) never */
|
|
41
|
+
reportPollMs?: number;
|
|
35
42
|
/** the sender; tests may replace it */
|
|
36
43
|
send?: (
|
|
37
44
|
target: { socketPath: string | null; token: string | null },
|
|
@@ -76,7 +83,8 @@ function legacyNotified(q: Queued, target: HostSessionRef): boolean {
|
|
|
76
83
|
}
|
|
77
84
|
}
|
|
78
85
|
|
|
79
|
-
|
|
86
|
+
/** A "submitting" claim left by a sender that died is ambiguous: retrying it may duplicate input. Under the notify lock. */
|
|
87
|
+
export function recoverSubmission(dir: string, eventId: string): void {
|
|
80
88
|
for (const a of readDelivery(dir)) {
|
|
81
89
|
if (a.status === "submitting" && a.eventIds.includes(eventId))
|
|
82
90
|
writeDeliveryAttempt(dir, {
|
|
@@ -119,6 +127,7 @@ export function finishedNotice(run: Run, d: Dispatch, r: RunRecord): Notice {
|
|
|
119
127
|
name: r.name,
|
|
120
128
|
role: r.role,
|
|
121
129
|
lane: r.lane,
|
|
130
|
+
thread: r.thread,
|
|
122
131
|
rung: r.rung,
|
|
123
132
|
status,
|
|
124
133
|
replyStatus: r.replyStatus,
|
|
@@ -129,6 +138,43 @@ export function finishedNotice(run: Run, d: Dispatch, r: RunRecord): Notice {
|
|
|
129
138
|
};
|
|
130
139
|
}
|
|
131
140
|
|
|
141
|
+
/** One report of a 2.0 run, as the session is told of it: the unit, what kind it is, its text. */
|
|
142
|
+
export function reportNotice(run: Run, e: Extract<RunEvent, { type: "report.posted" }>): Notice {
|
|
143
|
+
return {
|
|
144
|
+
kind: "report",
|
|
145
|
+
eventId: JSON.stringify([run.id, `report-${e.seq}`, "report"]),
|
|
146
|
+
runId: run.id,
|
|
147
|
+
runTitle: run.meta.title,
|
|
148
|
+
dispatchId: `report-${e.seq}`,
|
|
149
|
+
name: e.unit,
|
|
150
|
+
role: "run",
|
|
151
|
+
lane: null,
|
|
152
|
+
thread: null,
|
|
153
|
+
rung: "",
|
|
154
|
+
status: e.kind,
|
|
155
|
+
replyStatus: null,
|
|
156
|
+
secs: 0,
|
|
157
|
+
changedOwned: 0,
|
|
158
|
+
reply: e.text,
|
|
159
|
+
priority: e.kind === "parked" || e.kind === "failed" ? "next" : "later",
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A live fresh role's thread, as its CLI named it early in its stream; null before it did. */
|
|
164
|
+
function streamThread(d: Dispatch): string | null {
|
|
165
|
+
const a = adapterFor(d.admit.backend);
|
|
166
|
+
if (!a) return null;
|
|
167
|
+
for (const line of nonBlankLines(dispatchPaths(d.dir).events)) {
|
|
168
|
+
try {
|
|
169
|
+
const t = a.parse(line).thread;
|
|
170
|
+
if (t) return t;
|
|
171
|
+
} catch {
|
|
172
|
+
// a line the adapter cannot read names no thread
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
132
178
|
/** Spec §3.6: a live role that went quiet, once, at `next`. */
|
|
133
179
|
export function stalledNotice(run: Run, d: Dispatch, quietMs: number, now: number): Notice {
|
|
134
180
|
return {
|
|
@@ -140,6 +186,7 @@ export function stalledNotice(run: Run, d: Dispatch, quietMs: number, now: numbe
|
|
|
140
186
|
name: d.admit.name,
|
|
141
187
|
role: d.admit.role,
|
|
142
188
|
lane: d.admit.lane,
|
|
189
|
+
thread: d.admit.thread ?? streamThread(d),
|
|
143
190
|
rung: d.admit.rung,
|
|
144
191
|
status: `stalled: no output for ${Math.max(1, Math.round(quietMs / 60_000))} min`,
|
|
145
192
|
replyStatus: null,
|
|
@@ -242,6 +289,38 @@ function notifierFor(deps: Deps, o: NotifierOptions, retryEvent?: string): Notif
|
|
|
242
289
|
log("warn", "notify", { error: errorMessage(e), dispatch: q.notice.dispatchId });
|
|
243
290
|
}
|
|
244
291
|
}
|
|
292
|
+
// spec 1.5 plan 21: a notice never goes to a role's own thread (a `codex exec` thread exits after its turn,
|
|
293
|
+
// so what is queued there is lost). It fails loudly, and `status` says so, instead of enqueue-accepted.
|
|
294
|
+
const roles = new Map(due.map((q) => [q, roleThreadOf(q.run, target.sessionId)]));
|
|
295
|
+
for (const [q, role] of roles) {
|
|
296
|
+
if (role === null) continue;
|
|
297
|
+
due.splice(due.indexOf(q), 1);
|
|
298
|
+
const reason = `${ROLE_THREAD_REFUSAL} (${role}, ${target.host} ${target.sessionId}), not the orchestrator's; peek(run) from the orchestrator's session takes the run back`;
|
|
299
|
+
log("error", "notify", {
|
|
300
|
+
run: q.run.id,
|
|
301
|
+
dispatch: q.notice.dispatchId,
|
|
302
|
+
target: target.sessionId,
|
|
303
|
+
reason,
|
|
304
|
+
});
|
|
305
|
+
try {
|
|
306
|
+
writeDeliveryAttempt(q.dir, {
|
|
307
|
+
attemptId: crypto.randomUUID(),
|
|
308
|
+
target: {
|
|
309
|
+
host: target.host,
|
|
310
|
+
sessionId: target.sessionId,
|
|
311
|
+
hostSessionId: target.hostSessionId,
|
|
312
|
+
name: target.name,
|
|
313
|
+
},
|
|
314
|
+
eventIds: [q.notice.eventId],
|
|
315
|
+
at: new Date(deps.now()).toISOString(),
|
|
316
|
+
status: "failed",
|
|
317
|
+
msgId: null,
|
|
318
|
+
reason,
|
|
319
|
+
});
|
|
320
|
+
} catch (e) {
|
|
321
|
+
log("warn", "notify", { error: errorMessage(e), dispatch: q.notice.dispatchId });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
245
324
|
// nothing is sent unless every notice's claim is on disk. A claim that cannot be written rolls the batch's
|
|
246
325
|
// written claims back to failed (provably not submitted, so still deliverable), keeps that notice for a
|
|
247
326
|
// later pass, and claims the rest again at once: one bad file never holds back the others
|
|
@@ -430,7 +509,35 @@ function notifierFor(deps: Deps, o: NotifierOptions, retryEvent?: string): Notif
|
|
|
430
509
|
}
|
|
431
510
|
};
|
|
432
511
|
|
|
512
|
+
/** The reports of a 2.0 run this session owns: queued once each, under `<run>/reports/<seq>/`. */
|
|
513
|
+
const reportSizes = new Map<string, number>();
|
|
514
|
+
const enqueueReports = (run: Run, force: boolean): void => {
|
|
515
|
+
const size = statSync(eventsFile(run)).size;
|
|
516
|
+
if (!force && reportSizes.get(run.id) === size) return;
|
|
517
|
+
reportSizes.set(run.id, size);
|
|
518
|
+
for (const e of readEvents(run)) {
|
|
519
|
+
if (e.type !== "report.posted") continue;
|
|
520
|
+
const dir = join(run.dir, "reports", String(e.seq));
|
|
521
|
+
ensurePrivateDir(dir);
|
|
522
|
+
const notice = reportNotice(run, e);
|
|
523
|
+
queueNotice({ key: notice.eventId, run, dir, notice, mark: join(dir, "notified"), due: () => true });
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const scanReports = (force: boolean): void => {
|
|
528
|
+
if (stopped || !currentSession(deps)) return;
|
|
529
|
+
for (const run of listRuns().runs) {
|
|
530
|
+
try {
|
|
531
|
+
if (isDaemonRun(run) && owned(run)) enqueueReports(run, force);
|
|
532
|
+
} catch (e) {
|
|
533
|
+
log("warn", "notify", { run: run.id, error: errorMessage(e) });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
433
538
|
const onSettled = (s: Settled): void => {
|
|
539
|
+
// the run's reports tell the session of a 2.0 run, not each role's finish
|
|
540
|
+
if (isDaemonRun(s.run)) return;
|
|
434
541
|
try {
|
|
435
542
|
enqueue(s.run, s.d, s.record);
|
|
436
543
|
} catch (e) {
|
|
@@ -446,6 +553,10 @@ function notifierFor(deps: Deps, o: NotifierOptions, retryEvent?: string): Notif
|
|
|
446
553
|
for (const run of listRuns().runs) {
|
|
447
554
|
try {
|
|
448
555
|
if (!owned(run)) continue;
|
|
556
|
+
if (isDaemonRun(run)) {
|
|
557
|
+
enqueueReports(run, true);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
449
560
|
const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
|
|
450
561
|
for (const d of listDispatches(run)) {
|
|
451
562
|
// one dispatch's damaged evidence stays ambiguous for it alone: the rest of the run is still scanned
|
|
@@ -499,6 +610,15 @@ function notifierFor(deps: Deps, o: NotifierOptions, retryEvent?: string): Notif
|
|
|
499
610
|
settledHooks.add(onSettled);
|
|
500
611
|
stallHooks.add(onStall);
|
|
501
612
|
}
|
|
613
|
+
if (!retryEvent && o.reportPollMs) {
|
|
614
|
+
const poll = setInterval(() => scanReports(false), o.reportPollMs);
|
|
615
|
+
poll.unref();
|
|
616
|
+
const stopRest = n.stop;
|
|
617
|
+
n.stop = () => {
|
|
618
|
+
clearInterval(poll);
|
|
619
|
+
stopRest();
|
|
620
|
+
};
|
|
621
|
+
}
|
|
502
622
|
return n;
|
|
503
623
|
}
|
|
504
624
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { notifyMessage } from "../domain/notify.ts";
|
|
2
|
+
import type { NotifyMoment } from "../domain/profile.ts";
|
|
3
|
+
import { postNotify } from "../infra/ntfy.ts";
|
|
4
|
+
import type { Deps } from "./ports.ts";
|
|
5
|
+
import { runProfile } from "./run-pin.ts";
|
|
6
|
+
import type { Run } from "./run-store.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Tells the owner's phone about a moment of a run, when the run's profile lists that moment. False when it is
|
|
10
|
+
* not listed, or the message did not go out; it never throws.
|
|
11
|
+
*/
|
|
12
|
+
export async function sendRunNotice(
|
|
13
|
+
deps: Deps,
|
|
14
|
+
run: Run,
|
|
15
|
+
i: { unit: string; moment: NotifyMoment },
|
|
16
|
+
o: { fetch?: typeof fetch; timeoutMs?: number } = {},
|
|
17
|
+
): Promise<boolean> {
|
|
18
|
+
if (!runProfile(deps, run).notify.includes(i.moment)) return false;
|
|
19
|
+
// CATHERD_NO_END_PUSH=1 (the test preload sets it) keeps every real send off; a test's own fetch still goes
|
|
20
|
+
if (process.env.CATHERD_NO_END_PUSH === "1" && !o.fetch) return false;
|
|
21
|
+
return postNotify(
|
|
22
|
+
deps.profiles.notifyUrl(),
|
|
23
|
+
notifyMessage({ runId: run.id, runTitle: run.meta.title, unit: i.unit, moment: i.moment }),
|
|
24
|
+
o,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { RunRecord } from "../domain/record.ts";
|
|
2
|
+
import { liveSessionFile, readSessionFiles, type SessionFile } from "../infra/claude-session.ts";
|
|
3
|
+
import { awaitsCollect } from "../infra/dispatch-dir.ts";
|
|
4
|
+
import { listDispatches, liveDispatches } from "./dispatches.ts";
|
|
5
|
+
import { readRecords, type Run } from "./run-store.ts";
|
|
6
|
+
import { runOwner } from "./sessions.ts";
|
|
7
|
+
|
|
8
|
+
export const ORCHESTRATOR_STALL_MS = 5 * 60_000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Plan 22 (#42 finding 3): the line shows only for a current owner (its Claude Code session live, or seen in the
|
|
12
|
+
* last day) and an unread record that ended in the last day, so an abandoned run never reads "stalled" forever.
|
|
13
|
+
*/
|
|
14
|
+
export const WAIT_WINDOW_MS = 24 * 3_600_000;
|
|
15
|
+
|
|
16
|
+
export interface OrchestratorWait {
|
|
17
|
+
since: string;
|
|
18
|
+
seconds: number;
|
|
19
|
+
stalled: boolean;
|
|
20
|
+
unread: number;
|
|
21
|
+
/** when the line stops showing, unless the owner is seen again or another record ends */
|
|
22
|
+
until: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* When the owner was last seen acting on the run: it took the run (`since`), or one of the run's dispatches was
|
|
27
|
+
* admitted from its session.
|
|
28
|
+
*/
|
|
29
|
+
function ownerSeen(run: Run, owner: { sessionId: string; since: string }): number {
|
|
30
|
+
let seen = Date.parse(owner.since) || 0;
|
|
31
|
+
for (const d of listDispatches(run))
|
|
32
|
+
if (d.admit.sessionId?.toLowerCase() === owner.sessionId.toLowerCase())
|
|
33
|
+
seen = Math.max(seen, Date.parse(d.admit.admittedAt) || 0);
|
|
34
|
+
return seen;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pure diagnostic: queue acceptance cannot show that the orchestrator processed a completion. Null unless the
|
|
39
|
+
* run has a current owner, no live role, and an unread record that ended within WAIT_WINDOW_MS.
|
|
40
|
+
*/
|
|
41
|
+
export function orchestratorWait(
|
|
42
|
+
run: Run,
|
|
43
|
+
now: number,
|
|
44
|
+
records: RunRecord[] = readRecords(run).records,
|
|
45
|
+
hasLive = liveDispatches(run, now).length > 0,
|
|
46
|
+
files: () => SessionFile[] = readSessionFiles,
|
|
47
|
+
): OrchestratorWait | null {
|
|
48
|
+
const owner = runOwner(run);
|
|
49
|
+
if (!owner || hasLive) return null;
|
|
50
|
+
const unread = new Set(
|
|
51
|
+
listDispatches(run)
|
|
52
|
+
.filter((d) => awaitsCollect(d.dir))
|
|
53
|
+
.map((d) => d.admit.dispatchId),
|
|
54
|
+
);
|
|
55
|
+
const recent = records.filter(
|
|
56
|
+
(r) => unread.has(r.dispatchId) && now - (Date.parse(r.endedAt) || 0) < WAIT_WINDOW_MS,
|
|
57
|
+
);
|
|
58
|
+
if (!recent.length) return null;
|
|
59
|
+
const ended = Math.max(...recent.map((r) => Date.parse(r.endedAt) || 0));
|
|
60
|
+
const live = owner.host === "claude-code" && liveSessionFile(owner.sessionId, files()) !== null;
|
|
61
|
+
const seen = live ? now : ownerSeen(run, owner);
|
|
62
|
+
if (now - seen >= WAIT_WINDOW_MS) return null;
|
|
63
|
+
const since = Math.max(ended, Date.parse(owner.since) || 0);
|
|
64
|
+
const elapsed = Math.max(0, now - since);
|
|
65
|
+
return {
|
|
66
|
+
since: new Date(since).toISOString(),
|
|
67
|
+
seconds: Math.floor(elapsed / 1000),
|
|
68
|
+
stalled: elapsed >= ORCHESTRATOR_STALL_MS,
|
|
69
|
+
unread: records.filter((r) => unread.has(r.dispatchId)).length,
|
|
70
|
+
until: new Date(Math.min(ended, seen) + WAIT_WINDOW_MS).toISOString(),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The one "waiting for orchestrator" line every view shows (#42 finding 7): seconds and `stalled` from `since` at
|
|
76
|
+
* `now`, so a memoised wait stays true between recomputes; null once it is past `until`.
|
|
77
|
+
*/
|
|
78
|
+
export function waitingLine(w: OrchestratorWait | null | undefined, now: number): string | null {
|
|
79
|
+
if (!w || now >= Date.parse(w.until)) return null;
|
|
80
|
+
const elapsed = Math.max(0, now - Date.parse(w.since));
|
|
81
|
+
return `${elapsed >= ORCHESTRATOR_STALL_MS ? "stalled · " : ""}waiting for orchestrator ${Math.floor(elapsed / 1000)}s`;
|
|
82
|
+
}
|