tumwater 0.4.1 → 0.4.2
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 +4 -6
- package/dist/build-info.json +2 -2
- package/dist/src/agent/backend-traits.js +14 -0
- package/dist/src/agent/claude/claude-stream.js +27 -31
- package/dist/src/agent/codex/codex-rollout.js +38 -24
- package/dist/src/agent/codex/codex-stream.js +71 -38
- package/dist/src/agent/image-input.js +18 -87
- package/dist/src/agent/opencode/opencode-catalog.js +2 -2
- package/dist/src/agent/opencode/opencode-stream.js +4 -4
- package/dist/src/agent/run-agent.js +11 -2
- package/dist/src/agent/run-settle.js +22 -13
- package/dist/src/agent/run-spawn.js +3 -12
- package/dist/src/agent/run-stream.js +2 -10
- package/dist/src/agent/session-ledger.js +16 -0
- package/dist/src/backlog/backlog-archive.js +64 -165
- package/dist/src/backlog/backlog-eligibility.js +50 -58
- package/dist/src/backlog/backlog-file-bugs.js +133 -0
- package/dist/src/backlog/backlog-index-data.js +2 -2
- package/dist/src/backlog/backlog-md.js +38 -27
- package/dist/src/backlog/backlog-release.js +11 -19
- package/dist/src/backlog/backlog-structure.js +14 -1
- package/dist/src/backlog/backlog-write.js +1 -1
- package/dist/src/backlog/backlog.js +5 -4
- package/dist/src/backlog/question-answer.js +30 -14
- package/dist/src/baseline/main-baseline.js +30 -20
- package/dist/src/baseline/main-guardrails.js +54 -17
- package/dist/src/baseline/main-watch.js +1 -1
- package/dist/src/budget/budget-wiring.js +0 -5
- package/dist/src/budget/budget.js +2 -1
- package/dist/src/build/build-check-scoped.js +70 -41
- package/dist/src/change/change-data.js +4 -2
- package/dist/src/cli/cli-command-args.js +199 -98
- package/dist/src/cli/cli-flag-specs.js +1 -4
- package/dist/src/cli/cli-review-commands.js +2 -1
- package/dist/src/cli/cli-role-command.js +27 -50
- package/dist/src/cli/cli-start.js +62 -12
- package/dist/src/cli/follow-view.js +3 -1
- package/dist/src/cli/help.js +6 -7
- package/dist/src/cli/log-commands.js +3 -1
- package/dist/src/cli.js +3 -7
- package/dist/src/collections.js +21 -7
- package/dist/src/config/config-fallbacks.js +0 -4
- package/dist/src/config/config-live.js +179 -147
- package/dist/src/config/config-views.js +2 -7
- package/dist/src/config/config-write.js +19 -26
- package/dist/src/doctor/doctor-backlog-bugs.js +17 -115
- package/dist/src/doctor/doctor-backlog-plans.js +8 -8
- package/dist/src/doctor/doctor-backlog.js +22 -36
- package/dist/src/doctor/doctor-check-names.js +2 -2
- package/dist/src/doctor/doctor-claude-checks.js +2 -2
- package/dist/src/doctor/doctor-contention-checks.js +1 -1
- package/dist/src/doctor/doctor-holds.js +13 -11
- package/dist/src/doctor/doctor-model-checks.js +8 -184
- package/dist/src/doctor/doctor-notify.js +2 -5
- package/dist/src/doctor/doctor-opencode-checks.js +85 -48
- package/dist/src/doctor/doctor-project-checks.js +66 -17
- package/dist/src/doctor/doctor-role-notes.js +6 -24
- package/dist/src/doctor/doctor.js +6 -6
- package/dist/src/doctor/probe.js +1 -1
- package/dist/src/events/event-format.js +5 -15
- package/dist/src/events/event-labels.js +0 -1
- package/dist/src/events/event-legacy.js +1 -1
- package/dist/src/events/event-window.js +1 -1
- package/dist/src/events/events.js +15 -2
- package/dist/src/failure/failure-state-change.js +183 -68
- package/dist/src/failure/time-spend.js +1 -1
- package/dist/src/files/json-object.js +15 -2
- package/dist/src/gates/startup-gate.js +28 -28
- package/dist/src/git/slot-salvage.js +5 -4
- package/dist/src/gui/gui-admin-endpoints.js +2 -3
- package/dist/src/gui/gui-backlog-commands.js +1 -1
- package/dist/src/gui/gui-backlog-endpoints.js +1 -7
- package/dist/src/gui/gui-endpoint-commands.js +1 -1
- package/dist/src/gui/gui-operator-commands.js +0 -12
- package/dist/src/gui/gui-prompt-commands.js +26 -20
- package/dist/src/gui/gui-report-endpoints.js +2 -1
- package/dist/src/gui/gui-server.js +1 -2
- package/dist/src/history/history.js +102 -67
- package/dist/src/inbox/inbox-submit.js +1 -5
- package/dist/src/inbox/prompt-commands.js +24 -19
- package/dist/src/init/init.js +68 -15
- package/dist/src/merge/merge-core.js +1 -1
- package/dist/src/merge/merge-post-review-drain.js +1 -1
- package/dist/src/operator/operator-commands.js +68 -47
- package/dist/src/operator/retire.js +2 -1
- package/dist/src/orchestrator/retention.js +6 -5
- package/dist/src/paths.js +0 -6
- package/dist/src/pi/pi-watchdogs.js +20 -1
- package/dist/src/pi-extension/context-shake.js +55 -31
- package/dist/src/process/process.js +23 -0
- package/dist/src/process/supervisor.js +17 -1
- package/dist/src/prompt/gate-notes.js +9 -9
- package/dist/src/prompt/notes-backlog.js +11 -24
- package/dist/src/prompt/prompt-followup.js +4 -12
- package/dist/src/prompt/prompt.js +4 -5
- package/dist/src/report/report-data.js +10 -225
- package/dist/src/report/report-fold.js +20 -295
- package/dist/src/report/report-main-red.js +45 -42
- package/dist/src/report/report-render.js +66 -39
- package/dist/src/report/report-since-data.js +218 -0
- package/dist/src/report/report-usage-fold.js +288 -0
- package/dist/src/report/report.js +2 -1
- package/dist/src/review/known-flakes.js +3 -3
- package/dist/src/review/post-merge-review-accept.js +3 -2
- package/dist/src/review/post-merge-review.js +9 -9
- package/dist/src/review/quarantine-render.js +2 -1
- package/dist/src/roles/role-catalog.js +18 -30
- package/dist/src/roles/role-guidance.js +18 -43
- package/dist/src/roles/role-view.js +1 -2
- package/dist/src/run/ci-parity.js +5 -6
- package/dist/src/run/qa-coverage.js +4 -4
- package/dist/src/run/run-detail-data.js +23 -7
- package/dist/src/run/run-detail-render.js +6 -0
- package/dist/src/run/run-detail.js +44 -22
- package/dist/src/run/run-dismiss.js +43 -0
- package/dist/src/run/run-prompt.js +100 -56
- package/dist/src/run/run-resume.js +32 -31
- package/dist/src/run/run-stage.js +4 -21
- package/dist/src/run/run-timing.js +18 -4
- package/dist/src/run/run-verdict.js +8 -73
- package/dist/src/run/stage-backlog-checks.js +2 -29
- package/dist/src/run/stage-check.js +4 -12
- package/dist/src/runner/runner-outcome.js +0 -5
- package/dist/src/runner/runner-run.js +2 -79
- package/dist/src/runner/runner-state-load.js +0 -10
- package/dist/src/runner/runner.js +1 -32
- package/dist/src/scheduling/claims.js +3 -10
- package/dist/src/status/status-data.js +5 -9
- package/dist/src/status/status-role-notes.js +18 -14
- package/dist/src/status/status-roles.js +2 -35
- package/dist/src/text/datetime.js +8 -1
- package/dist/src/text/text.js +12 -0
- package/dist/src/ui/alert-feeds.js +7 -212
- package/dist/src/ui/badges.js +2 -3
- package/dist/src/ui/gui/gui-client-boot.js +19 -4
- package/dist/src/ui/gui/gui-client-drawer.js +1 -1
- package/dist/src/ui/gui/gui-client-report-chart.js +10 -8
- package/dist/src/ui/gui/gui-client-report.js +1 -2
- package/dist/src/ui/gui/gui-client-settings.js +0 -23
- package/dist/src/ui/gui/gui-client-team.js +26 -0
- package/dist/src/ui/gui/gui-icons.js +7 -1
- package/dist/src/ui/status-model.js +2 -2
- package/dist/src/ui/status-payload.js +3 -3
- package/dist/src/ui/status-render.js +3 -3
- package/dist/src/ui/team-alert-backlog.js +23 -54
- package/dist/src/ui/team-alert-shared.js +23 -10
- package/dist/src/ui/team-alert-status.js +8 -3
- package/dist/src/ui/team-alert-tail.js +1 -14
- package/dist/src/ui/team-alerts.js +6 -17
- package/dist/src/ui/transcript-tail.js +137 -94
- package/dist/src/ui/tui/tui-editor-answer.js +54 -44
- package/dist/src/ui/tui/tui-editor-attach.js +74 -63
- package/dist/src/ui/tui/tui-editor-backends.js +62 -56
- package/dist/src/ui/tui/tui-editor-brief.js +64 -54
- package/dist/src/ui/tui/tui-editor-budget.js +105 -93
- package/dist/src/ui/tui/tui-editor-config.js +2 -4
- package/dist/src/ui/tui/tui-editor-entry.js +171 -153
- package/dist/src/ui/tui/tui-editor-instances.js +2 -1
- package/dist/src/ui/tui/tui-editor-pause.js +209 -183
- package/dist/src/ui/tui/tui-editor-prompt.js +44 -34
- package/dist/src/ui/tui/tui-editor-queued.js +74 -64
- package/dist/src/ui/tui/tui-editor-quiet.js +121 -108
- package/dist/src/ui/tui/tui-editor-release.js +50 -42
- package/dist/src/ui/tui/tui-editor-retire.js +57 -46
- package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
- package/dist/src/ui/tui/tui-editor-role-file.js +58 -46
- package/dist/src/ui/tui/tui-editor-role-key.js +49 -42
- package/dist/src/ui/tui/tui-editor-search.js +70 -58
- package/dist/src/ui/tui/tui-frame.js +1 -1
- package/dist/src/ui/tui/tui-keys.js +1 -1
- package/dist/src/ui/tui/tui-pane-activity.js +62 -50
- package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
- package/dist/src/ui/tui/tui-pane-health.js +3 -24
- package/dist/src/ui/tui/tui-pane-history.js +20 -24
- package/dist/src/ui/tui/tui-pane-report.js +74 -57
- package/dist/src/ui/tui/tui-pane-session.js +3 -2
- package/dist/src/ui/tui/tui-pane-transcript.js +151 -128
- package/dist/src/ui/tui/tui-pane.js +201 -174
- package/dist/src/ui/tui/tui-panes.js +2 -3
- package/dist/src/ui/tui/tui.js +3 -3
- package/dist/src/verdict/fix-claim.js +18 -6
- package/dist/src/verdict/refusal.js +30 -6
- package/package.json +1 -1
- package/dist/src/doctor/image-probe.js +0 -257
- package/dist/src/ui/team-alert-backlog-feed.js +0 -181
- package/dist/src/ui/team-alert-merge.js +0 -183
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
/** The seam's answer to "who in this team can read images" (plans/image-input.md
|
|
2
|
-
* "Capability"). One resolver, `seamImageInput`, is the only place that rule lives
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
import { configForRole
|
|
8
|
-
import { BORROW_ORDER, resolveTierFallbacks } from "../config/config-fallbacks.js";
|
|
2
|
+
* "Capability"). One resolver, `seamImageInput`, is the only place that rule lives, so the
|
|
3
|
+
* answer is never reached two ways. It lives at runtime level because it needs the backend
|
|
4
|
+
* registry: a claude seam is always capable through the AgentBackend.imageInput hook, a pi
|
|
5
|
+
* seam reads the model's `input` in models.json, and any other backend answers null unless it
|
|
6
|
+
* declares a hook. */
|
|
7
|
+
import { configForRole } from "../config/config-views.js";
|
|
9
8
|
import { seamAgent } from "../config/model-selector.js";
|
|
10
|
-
import {
|
|
11
|
-
import { piModelImageInput, piPairFree, piModelsPath, readPiProviders } from "../pi/pi-models.js";
|
|
9
|
+
import { piModelImageInput, piModelsPath, readPiProviders } from "../pi/pi-models.js";
|
|
12
10
|
import { piListedImageInput } from "../pi/pi-listed-models.js";
|
|
13
11
|
import { backendById } from "./agent-backend.js";
|
|
14
|
-
import { imageProbeEntryFresh, imageProbeKey } from "../doctor/image-probe.js";
|
|
15
12
|
/** One seam's image-input capability (plans/image-input.md "Capability"): `true` when the
|
|
16
13
|
* seam's backend declares image support, `false` when it is text-only, `null` when it cannot
|
|
17
14
|
* be answered from what the team knows (a pair neither models.json nor the pi listing names, a
|
|
@@ -21,11 +18,10 @@ import { imageProbeEntryFresh, imageProbeKey } from "../doctor/image-probe.js";
|
|
|
21
18
|
* the default pi backend leaves it to models.json's `input` field, where absent means text-only
|
|
22
19
|
* (pi's own default for a custom model); a pair absent from models.json answers what
|
|
23
20
|
* `pi --list-models` last said (src/pi/pi-listed-models.ts), else null.
|
|
24
|
-
*
|
|
25
|
-
* reads
|
|
26
|
-
*
|
|
27
|
-
|
|
28
|
-
export function seamImageInput(seam, providers, probes, listed = null) {
|
|
21
|
+
* A claim in models.json is trusted without a live probe (the doctor's live probe was
|
|
22
|
+
* subtracted): a claimed pi seam reads true on the catalog. claude seams are answered by the
|
|
23
|
+
* hook. */
|
|
24
|
+
export function seamImageInput(seam, providers, listed = null) {
|
|
29
25
|
const backendId = seamAgent(seam);
|
|
30
26
|
const backend = backendById(backendId);
|
|
31
27
|
// A backend with an imageInput hook owns the answer outright (claude: true for every model).
|
|
@@ -34,79 +30,14 @@ export function seamImageInput(seam, providers, probes, listed = null) {
|
|
|
34
30
|
// An unknown backend without a hook has no catalog to consult — unknown, never assume.
|
|
35
31
|
if (backendId !== "pi")
|
|
36
32
|
return null;
|
|
37
|
-
|
|
38
|
-
if (base !== true)
|
|
39
|
-
return base;
|
|
40
|
-
// A claimed pi seam whose live probe failed (fresh, claim unchanged) reads false. Only pi is
|
|
41
|
-
// probed, so the probe key carries the pi backend.
|
|
42
|
-
const entry = probes?.[imageProbeKey(backendId, seam.provider, seam.model)];
|
|
43
|
-
if (entry?.verdict === "fail" && imageProbeEntryFresh(entry))
|
|
44
|
-
return false;
|
|
45
|
-
return true;
|
|
33
|
+
return piModelImageInput(providers, seam.provider, seam.model) ?? piListedImageInput(listed, seam.provider, seam.model);
|
|
46
34
|
}
|
|
47
|
-
/**
|
|
48
|
-
* (
|
|
49
|
-
*
|
|
50
|
-
|
|
51
|
-
* and takes the first tier whose model reads images. `regime` picks the pair set (see
|
|
52
|
-
* RerouteRegime); `modelsPath` and `account` are injectable so tests need no real pi install
|
|
53
|
-
* or account. */
|
|
54
|
-
export function imageReroute(config, role, regime = "primary", modelsPath = piModelsPath(), account = null, probes, providers = null, listed = null) {
|
|
55
|
-
const tier = roleSeamTier(config, role);
|
|
56
|
-
const candidates = BORROW_ORDER[tier].filter((t) => t !== tier);
|
|
57
|
-
if (candidates.length === 0)
|
|
58
|
-
return null;
|
|
59
|
-
const providerMap = providers ?? readPiProviders(modelsPath);
|
|
60
|
-
const capable = (rc) => seamImageInput(rc, providerMap, probes, listed) === true;
|
|
61
|
-
if (regime === "primary") {
|
|
62
|
-
for (const t of candidates) {
|
|
63
|
-
const rc = withModelOverrides(config, t, {});
|
|
64
|
-
if (capable(rc))
|
|
65
|
-
return { tier: t, config: rc };
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
// Fallback regime: only the tiers' free fallback pairs qualify (resolveTierFallbacks over the
|
|
70
|
-
// price check), never a priced primary model — a reroute must not spend past the cap.
|
|
71
|
-
const resolved = resolveTierFallbacks(config, (p) =>
|
|
72
|
-
// The free-ness lookup reads the one claude account held here (part 2a/9).
|
|
73
|
-
piPairFree(providerMap, p, (b) => (b === "claude" ? account : null)));
|
|
74
|
-
for (const t of candidates) {
|
|
75
|
-
const pair = resolved[t].pair;
|
|
76
|
-
if (!pair)
|
|
77
|
-
continue;
|
|
78
|
-
const rc = withModelOverrides(config, t, {
|
|
79
|
-
provider: pair.provider,
|
|
80
|
-
model: pair.model,
|
|
81
|
-
thinking: pair.thinking,
|
|
82
|
-
});
|
|
83
|
-
if (capable(rc))
|
|
84
|
-
return { tier: t, config: rc };
|
|
85
|
-
}
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
/** The config of imageReroute, for callers that want only the target model. */
|
|
89
|
-
export function imageRerouteTarget(config, role, regime = "primary", modelsPath = piModelsPath(), account = null, probes, providers = null, listed = null) {
|
|
90
|
-
return imageReroute(config, role, regime, modelsPath, account, probes, providers, listed)?.config ?? null;
|
|
91
|
-
}
|
|
92
|
-
/** A seam's model as one readable token, the same label the doctor's `image input` check uses
|
|
93
|
-
* (`claude/<id>` for a claude seam, `provider/model` for pi). The one home of the label, so
|
|
94
|
-
* the surface strings (the images alert, the role page, the status rows) and the doctor report
|
|
95
|
-
* cannot drift, and delegates to backend-traits' modelLabel. */
|
|
96
|
-
export function seamImageLabel(backend, provider, model) {
|
|
97
|
-
return modelLabel(backend, provider, model);
|
|
98
|
-
}
|
|
99
|
-
/** Compute RoleImageInfo for one role. `modelsPath` and `probes` are injectable; the providers
|
|
100
|
-
* catalog is read (stat-cached) here and also handed to imageReroute so a poll that asks many
|
|
101
|
-
* roles still parses the catalog once. All reads mirror the doctor's `image input` check, so
|
|
102
|
-
* the surface and the check agree about capability. */
|
|
103
|
-
export function roleImageInfo(config, role, modelsPath = piModelsPath(), probes, listed = null) {
|
|
35
|
+
/** Compute RoleImageInfo for one role. `modelsPath` and `listed` are injectable; the providers
|
|
36
|
+
* catalog is read (stat-cached) here. The reroute field is a literal null — the peer-tier image
|
|
37
|
+
* reroute was subtracted (part 5g/6), so no role names a reroute target; 5h removes the field. */
|
|
38
|
+
export function roleImageInfo(config, role, modelsPath = piModelsPath(), listed = null) {
|
|
104
39
|
const providers = readPiProviders(modelsPath);
|
|
105
40
|
const rc = configForRole(config, role);
|
|
106
|
-
const imageInput = seamImageInput(rc, providers,
|
|
107
|
-
|
|
108
|
-
const imageRerouteStr = rr
|
|
109
|
-
? `${rr.tier} (${seamImageLabel(rr.config.agent, rr.config.provider, rr.config.model)})`
|
|
110
|
-
: null;
|
|
111
|
-
return { imageInput, imageReroute: imageRerouteStr };
|
|
41
|
+
const imageInput = seamImageInput(rc, providers, listed);
|
|
42
|
+
return { imageInput, imageReroute: null };
|
|
112
43
|
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* `.tumwater/state/opencode-models.json` that survives a failed fetch, and
|
|
5
5
|
* `opencodeModelFree` as the one predicate part 6b/7's seam reads. No seam is wired here: that
|
|
6
6
|
* is part 6b/7 (the budget gate), part 6c/7 (a billedAt demotion on an observed cost) and
|
|
7
|
-
* part 7/7 (a startup-cache seed and doctor), so this module
|
|
8
|
-
*
|
|
7
|
+
* part 7/7 (a startup-cache seed and doctor), so this module only publishes the data and
|
|
8
|
+
* predicates those seam consumers read. */
|
|
9
9
|
import { spawnSync } from "node:child_process";
|
|
10
10
|
import { isJsonObject, validEntries } from "../../files/json-object.js";
|
|
11
11
|
import { readJsonFile, writeJsonAtomic } from "../../files/json-files.js";
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* `opencode_free_tier_refused` is added there once when any step was refused. `--thinking` is
|
|
17
17
|
* never passed, so no `reasoning` event appears; the mapping below still recognizes one so a
|
|
18
18
|
* future `--thinking` run degrades gracefully. */
|
|
19
|
+
import { transientProseKind } from "../backend-traits.js";
|
|
19
20
|
import { canonicalUsageBlock, isJsonObject, nonNegativeNumber, objectOrEmpty, parseJsonObject } from "../../files/json-object.js";
|
|
20
21
|
/** opencode's tool names → the canonical pi names the readers already know. `shell`→`bash` and
|
|
21
22
|
* `patch`→`edit` (pi has one edit tool); anything else is lower-cased so a future tool still
|
|
@@ -66,10 +67,9 @@ function classifyError(err) {
|
|
|
66
67
|
// Transient server, connection and timeout failures.
|
|
67
68
|
if (/\b(500|501|502|503|504|529)\b|internal server error|bad gateway|service unavailable|gateway timeout|overloaded/i.test(message))
|
|
68
69
|
return { kind: "transientBackend", backendKind: "server" };
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return { kind: "transientBackend", backendKind: "timeout" };
|
|
70
|
+
const prose = transientProseKind(message);
|
|
71
|
+
if (prose)
|
|
72
|
+
return prose;
|
|
73
73
|
return {};
|
|
74
74
|
}
|
|
75
75
|
/** The step's token counts and cost → pi's canonical usage block: the four counts, their sum
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import fs from "node:fs";
|
|
16
16
|
import os from "node:os";
|
|
17
17
|
import path from "node:path";
|
|
18
|
-
import { ensureDir } from "../files/files.js";
|
|
18
|
+
import { ensureDir, removeTree } from "../files/files.js";
|
|
19
19
|
import { runOnAbort, terminateChild } from "../process/process.js";
|
|
20
20
|
import { startPiWatchdogs } from "../pi/pi-watchdogs.js";
|
|
21
21
|
import { DEFAULT_BACKEND, backendById, registerAgentBackend } from "./agent-backend.js";
|
|
@@ -208,7 +208,16 @@ export function runAgent(opts) {
|
|
|
208
208
|
catch (err) {
|
|
209
209
|
// A synchronous throw in the run's build-and-spawn stage (claude's invocation rejects a
|
|
210
210
|
// run with no resolved model) rejects the run, preserving the contract a synchronous
|
|
211
|
-
// executor's throw used to get from the Promise constructor.
|
|
211
|
+
// executor's throw used to get from the Promise constructor. No child was spawned, so no
|
|
212
|
+
// `error`/`close` ever fires and the settle path that removes the private TMPDIR
|
|
213
|
+
// (run-stream.ts's finish) never runs — remove it here, best-effort like finish does, so
|
|
214
|
+
// a broken run does not leave a `tumwater-run-*` temp dir behind.
|
|
215
|
+
try {
|
|
216
|
+
removeTree(runTmp);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
/* left for the OS temp sweep */
|
|
220
|
+
}
|
|
212
221
|
reject(err instanceof Error ? err : new Error(`runAgent: ${String(err)}`));
|
|
213
222
|
}
|
|
214
223
|
});
|
|
@@ -144,6 +144,27 @@ export function settleExit(state) {
|
|
|
144
144
|
})
|
|
145
145
|
.catch(() => { });
|
|
146
146
|
}
|
|
147
|
+
/** Settle the run's `lastError` text in priority order: a failed blind image read names both
|
|
148
|
+
* the file and the model (plans/image-input.md "Failing the run"), then the abort, quiet-kill
|
|
149
|
+
* and timeout causes the watchdogs and signal path set, then the parser's own event-derived
|
|
150
|
+
* error, else the raw stderr tail. Undefined for a run that simply ended with a message. */
|
|
151
|
+
function settleErrorMessage(state, blindRead, failed, stderr, code) {
|
|
152
|
+
if (blindRead !== undefined) {
|
|
153
|
+
return `read ${blindRead}, but ${state.config.model ?? "the model"} cannot read images`;
|
|
154
|
+
}
|
|
155
|
+
if (state.aborted())
|
|
156
|
+
return "stopped by tumwater shutdown";
|
|
157
|
+
if (state.wd.quietKilled) {
|
|
158
|
+
return `killed as hung: no agent progress for over ${state.config.quietTimeoutSeconds}s`;
|
|
159
|
+
}
|
|
160
|
+
if (state.wd.timedOut) {
|
|
161
|
+
return state.wd.timedOutProgressing
|
|
162
|
+
? `timed out after ${state.wd.timeoutBudgetMs / 1000}s while still making progress — session and worktree edits preserved for resume`
|
|
163
|
+
: `timed out after ${state.wd.timeoutBudgetMs / 1000}s`;
|
|
164
|
+
}
|
|
165
|
+
return (state.parser.errorMessage ??
|
|
166
|
+
(failed ? stderr.trim().slice(-500) || `the agent exited ${code}` : undefined));
|
|
167
|
+
}
|
|
147
168
|
/** The run's `close` event: the process died and all its streams closed. Flush what the
|
|
148
169
|
* decoders held back, reconcile the backend's post-run work, classify the exit cause, and
|
|
149
170
|
* settle the run's result. */
|
|
@@ -182,20 +203,8 @@ export function settleClose(state, code) {
|
|
|
182
203
|
cls.transientPiCrash !== true &&
|
|
183
204
|
stderrBackend === undefined &&
|
|
184
205
|
(state.parser.configError || cls.configError === true);
|
|
185
|
-
// The blind-read failure names both the file and the model (plans/image-input.md
|
|
186
|
-
// "Failing the run"): take it over any generic exit text so lastError is actionable.
|
|
187
206
|
const blindRead = state.blindRead();
|
|
188
|
-
const errorMessage = blindRead
|
|
189
|
-
? `read ${blindRead}, but ${state.config.model ?? "the model"} cannot read images`
|
|
190
|
-
: state.aborted()
|
|
191
|
-
? "stopped by tumwater shutdown"
|
|
192
|
-
: state.wd.quietKilled
|
|
193
|
-
? `killed as hung: no agent progress for over ${state.config.quietTimeoutSeconds}s`
|
|
194
|
-
: state.wd.timedOut
|
|
195
|
-
? state.wd.timedOutProgressing
|
|
196
|
-
? `timed out after ${state.wd.timeoutBudgetMs / 1000}s while still making progress — session and worktree edits preserved for resume`
|
|
197
|
-
: `timed out after ${state.wd.timeoutBudgetMs / 1000}s`
|
|
198
|
-
: (state.parser.errorMessage ?? (failed ? stderr.trim().slice(-500) || `the agent exited ${code}` : undefined));
|
|
207
|
+
const errorMessage = settleErrorMessage(state, blindRead, failed, stderr, code);
|
|
199
208
|
state.finish(resultFromParser(state, {
|
|
200
209
|
ok: !failed,
|
|
201
210
|
transientPiCrash: cls.transientPiCrash === true,
|
|
@@ -15,7 +15,7 @@ import { ensureParentDir, rotateIfLarge } from "../files/files.js";
|
|
|
15
15
|
import { terminateChild } from "../process/process.js";
|
|
16
16
|
import { makeRunMarker } from "../process/run-marker.js";
|
|
17
17
|
import { PiStreamParser } from "../pi/pi-stream.js";
|
|
18
|
-
import { recordSession } from "./session-ledger.js";
|
|
18
|
+
import { recordSession, sessionRecordFor } from "./session-ledger.js";
|
|
19
19
|
import { errorMessage } from "../text/text.js";
|
|
20
20
|
import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
|
|
21
21
|
import { seamImageInput } from "./image-input.js";
|
|
@@ -44,15 +44,7 @@ function buildAndSpawn(backend, opts, pre) {
|
|
|
44
44
|
// session of its own kind instead of whatever file is newest in the dir. The id is the backend's
|
|
45
45
|
// own when it mints one ahead of the run (claude's UUID, or the resumed record's id); pi mints
|
|
46
46
|
// none and keeps the session name.
|
|
47
|
-
recordSession(opts.sessionDir,
|
|
48
|
-
agent: backend.id,
|
|
49
|
-
id: preSpawnSessionId,
|
|
50
|
-
kind: opts.kind,
|
|
51
|
-
name: opts.sessionName,
|
|
52
|
-
cwd: opts.cwd,
|
|
53
|
-
...(opts.config.model !== undefined ? { model: opts.config.model } : {}),
|
|
54
|
-
at: Date.now(),
|
|
55
|
-
});
|
|
47
|
+
recordSession(opts.sessionDir, sessionRecordFor(backend.id, preSpawnSessionId, opts));
|
|
56
48
|
const translator = backend.translator();
|
|
57
49
|
// Decode stdout incrementally instead of per chunk: a raw Buffer.toString("utf8") replaces any
|
|
58
50
|
// multi-byte character whose bytes straddle two 'data' events with U+FFFD, corrupting that
|
|
@@ -146,8 +138,7 @@ export function spawnPiRun(backend, logs, runTmp, opts) {
|
|
|
146
138
|
}
|
|
147
139
|
const parser = new PiStreamParser(opts.onToolCallStart, {
|
|
148
140
|
// The seam's effective image input (src/agent/image-input.ts): a text-only seam makes every
|
|
149
|
-
// image read blind even when pi adds no note —
|
|
150
|
-
// the parser checks against its own note rule.
|
|
141
|
+
// image read blind even when pi adds no note — the parser checks against its own note rule.
|
|
151
142
|
imageInput: seamImageInput(opts.config, readPiProviders(piModelsPath())),
|
|
152
143
|
onBlindRead: (path) => {
|
|
153
144
|
// Kill an authoring run at once, before the agent goes on working from a picture it never
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* `error`/`exit`/`close` listeners itself. This module imports only run-agent's, run-spawn's
|
|
10
10
|
* and run-settle's type surfaces, so there is no runtime import cycle. */
|
|
11
11
|
import { removeTree } from "../files/files.js";
|
|
12
|
-
import { recordSession } from "./session-ledger.js";
|
|
12
|
+
import { recordSession, sessionRecordFor } from "./session-ledger.js";
|
|
13
13
|
/** How many trailing stderr characters are carried into the next chunk's warning match, so a
|
|
14
14
|
* warning split across two `data` events is still seen. The warnings are short lines, so a few
|
|
15
15
|
* hundred characters always cover a straddle. */
|
|
@@ -60,15 +60,7 @@ export function createAgentStream(input) {
|
|
|
60
60
|
const sid = translator.sessionId?.();
|
|
61
61
|
if (sid !== undefined && sid !== preSpawnSessionId && !streamMintedSession) {
|
|
62
62
|
streamMintedSession = true;
|
|
63
|
-
recordSession(opts.sessionDir,
|
|
64
|
-
agent: backend.id,
|
|
65
|
-
id: sid,
|
|
66
|
-
kind: opts.kind,
|
|
67
|
-
name: opts.sessionName,
|
|
68
|
-
cwd: opts.cwd,
|
|
69
|
-
...(opts.config.model !== undefined ? { model: opts.config.model } : {}),
|
|
70
|
-
at: Date.now(),
|
|
71
|
-
});
|
|
63
|
+
recordSession(opts.sessionDir, sessionRecordFor(backend.id, sid, opts));
|
|
72
64
|
}
|
|
73
65
|
};
|
|
74
66
|
// Split native stdout into complete lines, translate each, and feed the canonical lines to
|
|
@@ -38,6 +38,22 @@ export function recordSession(dir, record) {
|
|
|
38
38
|
// Best effort: a lost ledger line costs resumability, not the run.
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
/** The one run's ledger record, minus the id the two recording sites mint differently — the
|
|
42
|
+
* pre-spawn id in run-spawn.ts, or the stream-minted session id in run-stream.ts. One place
|
|
43
|
+
* for the record's non-id shape (agent, kind, name, cwd, the optional model and the stamp), so
|
|
44
|
+
* the two appends cannot drift on the model spread or the required fields. `agent` is the
|
|
45
|
+
* owning backend's id; the run options supply the rest. */
|
|
46
|
+
export function sessionRecordFor(agent, id, opts) {
|
|
47
|
+
return {
|
|
48
|
+
agent,
|
|
49
|
+
id,
|
|
50
|
+
kind: opts.kind,
|
|
51
|
+
name: opts.sessionName,
|
|
52
|
+
cwd: opts.cwd,
|
|
53
|
+
...(opts.config.model !== undefined ? { model: opts.config.model } : {}),
|
|
54
|
+
at: Date.now(),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
41
57
|
/** True when `dir` holds at least one session file a backend could resume by directory. This is
|
|
42
58
|
* the backend-neutral filesystem check behind pi's `--continue` and the fallback for an
|
|
43
59
|
* unregistered backend. The ledger file shares the dir and is a `.jsonl` too, so it is
|
|
@@ -30,11 +30,9 @@ import { commitAuthorArgs, committerIdentArgs, git, gitTry } from "../git/git-ru
|
|
|
30
30
|
import { mergeLockDir } from "../paths.js";
|
|
31
31
|
import { plural } from "../text/phrases.js";
|
|
32
32
|
import { collapseWhitespace, errorFirstLine, errorMessage } from "../text/text.js";
|
|
33
|
-
import { FINISHED_ARCHIVES, fenceTracker, fencedHeadingTitle
|
|
33
|
+
import { FINISHED_ARCHIVES, fenceTracker, fencedHeadingTitle } from "./backlog-md.js";
|
|
34
34
|
import { actionableEntryRanges } from "./backlog-index-data.js";
|
|
35
35
|
import { strandedPlanEntries } from "./backlog-structure.js";
|
|
36
|
-
import { appendBugEntryLocked } from "./backlog-write.js";
|
|
37
|
-
import { formatDate } from "../text/datetime.js";
|
|
38
36
|
/** The placeholder a role leaves under an emptied section: never archived content. */
|
|
39
37
|
const PLACEHOLDER = /^_None yet\._$/;
|
|
40
38
|
/** The text a new archive file starts with: a title, how to read the file, and the section
|
|
@@ -133,6 +131,57 @@ function entryBlocks(lines, entries) {
|
|
|
133
131
|
}
|
|
134
132
|
return out;
|
|
135
133
|
}
|
|
134
|
+
/** The selected entries of `entries`: the keep slice, or the baseKeys/park filter. */
|
|
135
|
+
function selectArchiveEntries(entries, select) {
|
|
136
|
+
return "keep" in select
|
|
137
|
+
? entries.slice(Math.max(0, select.keep))
|
|
138
|
+
: entries.filter((e) => !select.baseKeys.has(archiveKey(e.title)) && !(select.park?.has(archiveKey(e.title)) ?? false));
|
|
139
|
+
}
|
|
140
|
+
/** Add the 0-based live lines of `entries` to `into`. */
|
|
141
|
+
function addEntryLines(entries, into) {
|
|
142
|
+
for (const e of entries)
|
|
143
|
+
for (let i = e.start - 1; i < e.end; i++)
|
|
144
|
+
into.add(i);
|
|
145
|
+
}
|
|
146
|
+
/** The bulk-archive split: the tail past the kept entries moves but for the dropped and staying
|
|
147
|
+
* entries, and a `_None yet._` placeholder stays in the live section, never in the archive.
|
|
148
|
+
* null when no entry sits past the kept ones. */
|
|
149
|
+
function splitByKeep(lines, span, entries, keep, staying, droppedLine) {
|
|
150
|
+
const from = keep <= 0 ? span.heading + 1 : (entries[keep]?.start ?? 0) - 1;
|
|
151
|
+
if (from < 0)
|
|
152
|
+
return null; // no entry past the kept ones
|
|
153
|
+
// The tail moves but for the dropped and staying entries; a `_None yet._` placeholder stays
|
|
154
|
+
// in the live section, never in the archive. Staying entries follow the kept ones.
|
|
155
|
+
const stayLine = new Set();
|
|
156
|
+
addEntryLines(staying, stayLine);
|
|
157
|
+
const tail = [];
|
|
158
|
+
for (let i = from; i < span.end; i++)
|
|
159
|
+
if (!droppedLine.has(i) && !stayLine.has(i))
|
|
160
|
+
tail.push(lines[i] ?? "");
|
|
161
|
+
const moved = trimBlank(tail.filter((l) => !PLACEHOLDER.test(l.trim())));
|
|
162
|
+
const stayingBlocks = entryBlocks(lines, staying);
|
|
163
|
+
const head = keep <= 0 ? [] : trimBlank(lines.slice(span.heading + 1, from));
|
|
164
|
+
const kept = head.length > 0 && stayingBlocks.length > 0 ? [...head, "", ...stayingBlocks] : [...head, ...stayingBlocks];
|
|
165
|
+
return {
|
|
166
|
+
kept: kept.length === 0 ? tail.filter((l) => PLACEHOLDER.test(l.trim())).slice(0, 1) : kept,
|
|
167
|
+
moved,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/** The commit-time-sweep split: the kept body drops the moved and dropped lines, refusing when
|
|
171
|
+
* no archive exists and other content would stay in the live section. */
|
|
172
|
+
function splitByBaseKeys(lines, span, archiveMd, moving, droppedLine) {
|
|
173
|
+
const leaves = new Set(droppedLine);
|
|
174
|
+
addEntryLines(moving, leaves);
|
|
175
|
+
let kept = [];
|
|
176
|
+
for (let i = span.heading + 1; i < span.end; i++)
|
|
177
|
+
if (!leaves.has(i))
|
|
178
|
+
kept.push(lines[i] ?? "");
|
|
179
|
+
kept = trimBlank(kept);
|
|
180
|
+
if (archiveMd === null && kept.some((l) => l.trim() !== "" && !PLACEHOLDER.test(l.trim())))
|
|
181
|
+
return null;
|
|
182
|
+
const moved = entryBlocks(lines, moving);
|
|
183
|
+
return { kept, moved };
|
|
184
|
+
}
|
|
136
185
|
/** Move the selected part of `liveMd`'s finished section to the top of `archiveMd`'s (a new
|
|
137
186
|
* archive starts from archiveTemplate), keeping the moved lines' order. A selected entry whose
|
|
138
187
|
* heading the archive already holds never moves (EntryFate): with the same text it is dropped
|
|
@@ -147,9 +196,7 @@ export function archiveFinishedText(liveMd, archiveMd, spec, select) {
|
|
|
147
196
|
if (span === null)
|
|
148
197
|
return null;
|
|
149
198
|
const entries = entriesIn(liveMd, span, spec.section);
|
|
150
|
-
const selected =
|
|
151
|
-
? entries.slice(Math.max(0, select.keep))
|
|
152
|
-
: entries.filter((e) => !select.baseKeys.has(archiveKey(e.title)) && !(select.park?.has(archiveKey(e.title)) ?? false));
|
|
199
|
+
const selected = selectArchiveEntries(entries, select);
|
|
153
200
|
if (!("keep" in select) && selected.length === 0)
|
|
154
201
|
return null;
|
|
155
202
|
const archiveText = archiveMd ?? archiveTemplate(spec);
|
|
@@ -161,46 +208,14 @@ export function archiveFinishedText(liveMd, archiveMd, spec, select) {
|
|
|
161
208
|
const withFate = (fate) => selected.filter((_, i) => fates[i] === fate);
|
|
162
209
|
// The 0-based live lines of the entries that leave the live section without moving.
|
|
163
210
|
const droppedLine = new Set();
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
return null; // no entry past the kept ones
|
|
173
|
-
// The tail moves but for the dropped and staying entries; a `_None yet._` placeholder stays
|
|
174
|
-
// in the live section, never in the archive. Staying entries follow the kept ones.
|
|
175
|
-
const stayLine = new Set();
|
|
176
|
-
for (const e of withFate("stay"))
|
|
177
|
-
for (let i = e.start - 1; i < e.end; i++)
|
|
178
|
-
stayLine.add(i);
|
|
179
|
-
const tail = [];
|
|
180
|
-
for (let i = from; i < span.end; i++)
|
|
181
|
-
if (!droppedLine.has(i) && !stayLine.has(i))
|
|
182
|
-
tail.push(lines[i] ?? "");
|
|
183
|
-
moved = trimBlank(tail.filter((l) => !PLACEHOLDER.test(l.trim())));
|
|
184
|
-
const staying = entryBlocks(lines, withFate("stay"));
|
|
185
|
-
const head = select.keep <= 0 ? [] : trimBlank(lines.slice(span.heading + 1, from));
|
|
186
|
-
kept = head.length > 0 && staying.length > 0 ? [...head, "", ...staying] : [...head, ...staying];
|
|
187
|
-
if (kept.length === 0)
|
|
188
|
-
kept = tail.filter((l) => PLACEHOLDER.test(l.trim())).slice(0, 1);
|
|
189
|
-
}
|
|
190
|
-
else {
|
|
191
|
-
const leaves = new Set(droppedLine);
|
|
192
|
-
for (const e of withFate("move"))
|
|
193
|
-
for (let i = e.start - 1; i < e.end; i++)
|
|
194
|
-
leaves.add(i);
|
|
195
|
-
kept = [];
|
|
196
|
-
for (let i = span.heading + 1; i < span.end; i++)
|
|
197
|
-
if (!leaves.has(i))
|
|
198
|
-
kept.push(lines[i] ?? "");
|
|
199
|
-
kept = trimBlank(kept);
|
|
200
|
-
if (archiveMd === null && kept.some((l) => l.trim() !== "" && !PLACEHOLDER.test(l.trim())))
|
|
201
|
-
return null;
|
|
202
|
-
moved = entryBlocks(lines, withFate("move"));
|
|
203
|
-
}
|
|
211
|
+
addEntryLines(withFate("drop"), droppedLine);
|
|
212
|
+
const split = "keep" in select
|
|
213
|
+
? splitByKeep(lines, span, entries, select.keep, withFate("stay"), droppedLine)
|
|
214
|
+
: splitByBaseKeys(lines, span, archiveMd, withFate("move"), droppedLine);
|
|
215
|
+
if (split === null)
|
|
216
|
+
return null;
|
|
217
|
+
const moved = split.moved;
|
|
218
|
+
const kept = split.kept;
|
|
204
219
|
const dropped = withFate("drop").map((e) => e.title);
|
|
205
220
|
if (moved.length === 0 && dropped.length === 0)
|
|
206
221
|
return null;
|
|
@@ -327,9 +342,9 @@ function planOrSkip(root, keep) {
|
|
|
327
342
|
}
|
|
328
343
|
}
|
|
329
344
|
/** If the primary checkout is not on the base branch, the reason a main-side write should be
|
|
330
|
-
* skipped, else null. Both `archiveFinishedOnMain` and `fileBugsOnMain`
|
|
331
|
-
* committing a backlog file. */
|
|
332
|
-
async function primaryOffBaseBranchReason(root) {
|
|
345
|
+
* skipped, else null. Both `archiveFinishedOnMain` and `fileBugsOnMain` (backlog-file-bugs.ts)
|
|
346
|
+
* guard on this before committing a backlog file. */
|
|
347
|
+
export async function primaryOffBaseBranchReason(root) {
|
|
333
348
|
const primary = await currentBranch(root);
|
|
334
349
|
const base = targetBranch(loadConfigSafe(root).config?.baseBranch, primary);
|
|
335
350
|
if (primary !== base) {
|
|
@@ -421,119 +436,3 @@ export async function archiveAtTeamStart(root) {
|
|
|
421
436
|
warnEventBestEffort(root, TUMWATER_SOURCE, `finished backlog entries were not archived: ${errorMessage(err)}`);
|
|
422
437
|
}
|
|
423
438
|
}
|
|
424
|
-
/** The start of a quarantine entry's title for `test`, up to the date. appendBugEntryLocked
|
|
425
|
-
* collapses whitespace in the title, so the test part is collapsed the same way; collapsing the
|
|
426
|
-
* whole prefix would trim its trailing space. */
|
|
427
|
-
function quarantineTitlePrefix(test) {
|
|
428
|
-
return collapseWhitespace(`\`${test}\` failed, then passed on the same tree`) + " (quarantined by tumwater ";
|
|
429
|
-
}
|
|
430
|
-
/** Output fenced into a bug body: a fence of its own length cannot be closed by the quoted text. */
|
|
431
|
-
function fencedTail(tail) {
|
|
432
|
-
return ["~~~~", ...tail.replaceAll("~~~~", "~~~").split("\n").slice(-30), "~~~~"].join("\n");
|
|
433
|
-
}
|
|
434
|
-
/** File one BUGS.md `## Open` entry per given entry and commit BUGS.md on the base branch as one
|
|
435
|
-
* `tumwater(operator):` commit (the subject built from the number of entries actually appended is
|
|
436
|
-
* stamped under the operator role), under the merge lock and the primary-worktree lock exactly as
|
|
437
|
-
* archiveFinishedOnMain does. Skipped,
|
|
438
|
-
* writing nothing, when the primary checkout is off the base branch, BUGS.md has uncommitted
|
|
439
|
-
* changes, or BUGS.md exists but cannot be read; a failed append or commit restores BUGS.md.
|
|
440
|
-
* Every entry is appended verbatim as its own `## Open` heading — unless its `ifOpen` dedupe
|
|
441
|
-
* matches an existing heading, which is reused instead — and the returned headings are the
|
|
442
|
-
* entry headings in input order. */
|
|
443
|
-
export async function fileBugsOnMain(root, entries, subject, opts = {}) {
|
|
444
|
-
return withLock(mergeLockDir(root), () => withPrimaryWorktreeLockAsync(root, async () => {
|
|
445
|
-
const offBase = await primaryOffBaseBranchReason(root);
|
|
446
|
-
if (offBase !== null)
|
|
447
|
-
return { status: "skipped", reason: offBase };
|
|
448
|
-
const status = await gitTry(root, "status", "--porcelain", "--", "BUGS.md");
|
|
449
|
-
if (status === null)
|
|
450
|
-
return { status: "skipped", reason: "could not read git status" };
|
|
451
|
-
if (status.trim() !== "" && !status.startsWith("??")) {
|
|
452
|
-
return { status: "skipped", reason: "BUGS.md has uncommitted changes" };
|
|
453
|
-
}
|
|
454
|
-
const file = path.join(root, "BUGS.md");
|
|
455
|
-
let before;
|
|
456
|
-
try {
|
|
457
|
-
before = readTextIfExists(file);
|
|
458
|
-
}
|
|
459
|
-
catch (err) {
|
|
460
|
-
return { status: "skipped", reason: `BUGS.md could not be read: ${errorFirstLine(err)}` };
|
|
461
|
-
}
|
|
462
|
-
const open = before === null ? [] : parseEntryDetails(before, "Open").map((e) => e.title);
|
|
463
|
-
const headings = [];
|
|
464
|
-
let appended = 0;
|
|
465
|
-
const restoreBugs = async () => {
|
|
466
|
-
await gitTry(root, "reset", "-q", "--", "BUGS.md");
|
|
467
|
-
if (before === null)
|
|
468
|
-
fs.rmSync(file, { force: true });
|
|
469
|
-
else
|
|
470
|
-
writeTextAtomic(file, before);
|
|
471
|
-
};
|
|
472
|
-
try {
|
|
473
|
-
for (const entry of entries) {
|
|
474
|
-
if (entry.ifOpen !== undefined) {
|
|
475
|
-
const existing = entry.ifOpen(open);
|
|
476
|
-
if (existing !== undefined) {
|
|
477
|
-
headings.push(existing);
|
|
478
|
-
continue;
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
headings.push(appendBugEntryLocked(root, entry.title, entry.body));
|
|
482
|
-
appended++;
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
catch (err) {
|
|
486
|
-
await restoreBugs();
|
|
487
|
-
return { status: "skipped", reason: `a BUGS.md entry could not be written: ${errorFirstLine(err)}` };
|
|
488
|
-
}
|
|
489
|
-
if (appended === 0)
|
|
490
|
-
return { status: "filed", headings };
|
|
491
|
-
const commitSubject = stampedSubject("operator", // vocabulary-ok: the tumwater(operator): commit prefix is a machine string
|
|
492
|
-
subject(appended));
|
|
493
|
-
try {
|
|
494
|
-
await git(root, "add", "--", "BUGS.md");
|
|
495
|
-
await git(root, ...(await committerIdentArgs(root)), "commit", ...commitAuthorArgs(), "--only", "-m", commitSubject, "--", "BUGS.md");
|
|
496
|
-
}
|
|
497
|
-
catch (err) {
|
|
498
|
-
await restoreBugs();
|
|
499
|
-
return { status: "skipped", reason: `the commit failed: ${errorFirstLine(err)}` };
|
|
500
|
-
}
|
|
501
|
-
return { status: "filed", headings };
|
|
502
|
-
}), opts.lockTimeoutMs);
|
|
503
|
-
}
|
|
504
|
-
/** File one BUGS.md `## Open` entry per newly quarantined test and commit BUGS.md on the base
|
|
505
|
-
* branch as one `tumwater(operator):` commit, delegating the locked write/commit to
|
|
506
|
-
* fileBugsOnMain and preserving the existing subject. A test whose quarantine entry is already
|
|
507
|
-
* under `## Open` (a filing whose markQuarantineFiled failed after the commit) gets that heading
|
|
508
|
-
* back and is not filed again — the dedupe runs inside fileBugsOnMain under the merge lock (via
|
|
509
|
-
* each entry's `ifOpen`), so concurrent quarantine filings cannot both append a duplicate, and
|
|
510
|
-
* the real skip on a dirty or off-base BUGS.md is enforced under the same lock. */
|
|
511
|
-
export async function fileQuarantineBugsOnMain(root, tests, opts = {}) {
|
|
512
|
-
const date = formatDate(new Date());
|
|
513
|
-
const toFile = [];
|
|
514
|
-
for (const { test, tail } of tests) {
|
|
515
|
-
const prefix = quarantineTitlePrefix(test);
|
|
516
|
-
const body = [
|
|
517
|
-
`The check failed on this test and then passed on a re-run of the same tree, so tumwater quarantined it: it counts as a known failure until this entry leaves \`## Open\`. Find why it is unstable and fix it.`,
|
|
518
|
-
...(tail ? ["", fencedTail(tail)] : []),
|
|
519
|
-
].join("\n");
|
|
520
|
-
toFile.push({
|
|
521
|
-
title: `${prefix}${date})`,
|
|
522
|
-
body,
|
|
523
|
-
// Dedupe under the lock, against the headings fileBugsOnMain reads: the reused heading
|
|
524
|
-
// (not a duplicate) is returned in headings and zipped back to this test below.
|
|
525
|
-
ifOpen: (open) => open.find((t) => t.startsWith(prefix)),
|
|
526
|
-
});
|
|
527
|
-
}
|
|
528
|
-
const outcome = await fileBugsOnMain(root, toFile,
|
|
529
|
-
// Size the subject by what was actually appended: tests whose `## Open` entry was already
|
|
530
|
-
// present are reused, not filed, so a mixed batch keeps the original "File N …" wording
|
|
531
|
-
// with N the count of new entries only.
|
|
532
|
-
(appended) => `File ${plural(appended, "quarantined flaky test", "quarantined flaky tests")} in BUGS.md`, opts);
|
|
533
|
-
if (outcome.status === "skipped")
|
|
534
|
-
return { status: "skipped", reason: outcome.reason };
|
|
535
|
-
// fileBugsOnMain's headings align with `tests` in order (reused headings or appended titles).
|
|
536
|
-
const bugs = new Map();
|
|
537
|
-
outcome.headings.forEach((heading, i) => bugs.set(tests[i].test, heading));
|
|
538
|
-
return { status: "filed", bugs };
|
|
539
|
-
}
|