@nanobpm/nano-workforce 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +60 -0
- package/.github/workflows/release.yml +58 -0
- package/.releaserc.json +17 -0
- package/AGENTS.md +168 -0
- package/CHANGELOG.md +231 -0
- package/LICENSE +202 -0
- package/README.md +303 -0
- package/SPEC.md +492 -0
- package/actions/abandon.test.ts +93 -0
- package/actions/abandon.ts +23 -0
- package/actions/blackboard.test.ts +195 -0
- package/actions/blackboard.ts +76 -0
- package/actions/cancel.ts +29 -0
- package/actions/feature-answer-hook.ts +44 -0
- package/actions/message.ts +49 -0
- package/actions/plan-hook.ts +19 -0
- package/actions/plan-start.ts +17 -0
- package/actions/start.ts +19 -0
- package/actions/status.ts +22 -0
- package/actions/webhook-submit.ts +21 -0
- package/app/abandon.test.ts +97 -0
- package/app/abandon.ts +105 -0
- package/app/baseGuard.test.ts +35 -0
- package/app/baseGuard.ts +62 -0
- package/app/blackboard.test.ts +295 -0
- package/app/blackboard.ts +301 -0
- package/app/github.test.ts +59 -0
- package/app/github.ts +647 -0
- package/app/mergeExclusion.test.ts +168 -0
- package/app/mergeExclusion.ts +211 -0
- package/app/mergeProtocol.test.ts +124 -0
- package/app/mergeProtocol.ts +193 -0
- package/app/mergeRebaseArm.test.ts +72 -0
- package/app/mergeTrain.test.ts +91 -0
- package/app/mergeTrain.ts +117 -0
- package/app/persist-escalation.test.ts +119 -0
- package/app/persist-round.test.ts +65 -0
- package/app/plan.test.ts +317 -0
- package/app/plan.ts +321 -0
- package/app/record-plan-review.test.ts +38 -0
- package/app/reviewWait.test.ts +70 -0
- package/app/reviewWait.ts +59 -0
- package/app/rounds.test.ts +74 -0
- package/app/rounds.ts +48 -0
- package/app/service.test.ts +101 -0
- package/app/service.ts +895 -0
- package/app/taskDelta.test.ts +144 -0
- package/app/taskDelta.ts +175 -0
- package/app/trialMerge.test.ts +15 -0
- package/app/trialMerge.ts +102 -0
- package/app/waves.test.ts +128 -0
- package/app/waves.ts +116 -0
- package/assets/icon.svg +13 -0
- package/components/review-round.json +69 -0
- package/db/migrations/001_init.sql +46 -0
- package/db/migrations/002_transcript.sql +7 -0
- package/db/migrations/003_open_escalation.sql +8 -0
- package/db/migrations/004_merge.sql +36 -0
- package/db/migrations/004_planning.sql +37 -0
- package/db/migrations/005_job_activation.sql +15 -0
- package/db/migrations/005_plan_deps.sql +20 -0
- package/db/migrations/006_plan_review.sql +22 -0
- package/db/migrations/006_task_escalation.sql +52 -0
- package/db/migrations/007_plan_review_job_key.sql +14 -0
- package/db/migrations/007_wave_gate.sql +16 -0
- package/db/migrations/008_review_nudge.sql +9 -0
- package/db/migrations/009_plan_blackboard.sql +46 -0
- package/db/migrations/010_plan_task_deltas.sql +27 -0
- package/db/migrations/011_plan_merge_exclusions.sql +26 -0
- package/db/migrations/012_merge_protocol_attempt.sql +4 -0
- package/db/migrations/013_merge_train_waiting_lane.sql +6 -0
- package/db/migrations/014_plan_trial_merges.sql +21 -0
- package/db/migrations/015_pr_abandon_token.sql +9 -0
- package/deno.json +24 -0
- package/deno.lock +1776 -0
- package/main.ts +71 -0
- package/nano-ide.ext.json +7 -0
- package/nano.app.json +138 -0
- package/nanobpm.project.json +20 -0
- package/package.json +56 -0
- package/pages/epic.page.json +195 -0
- package/pages/home.page.json +296 -0
- package/prompts/feature.md +132 -0
- package/prompts/fix-ci.md +65 -0
- package/prompts/plan-review.md +69 -0
- package/prompts/plan.md +183 -0
- package/prompts/rebase.md +82 -0
- package/prompts/review-round.md +171 -0
- package/prompts/trial-merge.md +43 -0
- package/renovate.json +21 -0
- package/resources/processes/convergence-loop.bpmn +399 -0
- package/resources/processes/merge-loop.bpmn +585 -0
- package/resources/processes/plan-fanout.bpmn +546 -0
- package/scripts/check-agent-prompts.test.ts +84 -0
- package/scripts/check-agent-prompts.ts +143 -0
- package/scripts/layout-bpmn.ts +99 -0
- package/scripts/purge-db.ts +57 -0
- package/scripts/upgrade-from-pack.ts +334 -0
- package/tsconfig.json +51 -0
- package/workers/arm-merge/worker.ts +18 -0
- package/workers/finalize/worker.ts +89 -0
- package/workers/mark-merged/worker.ts +21 -0
- package/workers/merge/worker.ts +119 -0
- package/workers/persist-escalation/worker.ts +107 -0
- package/workers/persist-round/worker.ts +52 -0
- package/workers/persist-task-escalation/worker.ts +112 -0
- package/workers/record-plan/worker.ts +135 -0
- package/workers/record-plan-review/worker.ts +92 -0
- package/workers/record-results/worker.ts +30 -0
- package/workers/record-trial-merge/worker.test.ts +104 -0
- package/workers/record-trial-merge/worker.ts +88 -0
- package/workers/record-wave/worker.test.ts +221 -0
- package/workers/record-wave/worker.ts +308 -0
- package/workers/select-wave/worker.test.ts +130 -0
- package/workers/select-wave/worker.ts +84 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Contract for the review-wait liveness policy — the ISO-8601 timeout validation handed to the
|
|
2
|
+
// process's timer catch, and the Copilot re-request nudge cooldown. Both are env-driven, so a
|
|
3
|
+
// blank/malformed operator value must fall back to a sane default rather than deploy an
|
|
4
|
+
// uninterpretable timer or a runaway nudge interval. Run with `deno test -A`.
|
|
5
|
+
import { assertEquals } from "jsr:@std/assert@1";
|
|
6
|
+
import {
|
|
7
|
+
clampNudgeMinutes,
|
|
8
|
+
DEFAULT_REVIEW_NUDGE_MINUTES,
|
|
9
|
+
DEFAULT_REVIEW_WAIT_TIMEOUT,
|
|
10
|
+
MAX_REVIEW_NUDGE_MINUTES,
|
|
11
|
+
reviewWaitTimeout,
|
|
12
|
+
} from "./reviewWait.ts";
|
|
13
|
+
|
|
14
|
+
Deno.test("reviewWaitTimeout: blank / absent / malformed → default", () => {
|
|
15
|
+
assertEquals(reviewWaitTimeout(undefined), DEFAULT_REVIEW_WAIT_TIMEOUT);
|
|
16
|
+
assertEquals(reviewWaitTimeout(""), DEFAULT_REVIEW_WAIT_TIMEOUT);
|
|
17
|
+
assertEquals(reviewWaitTimeout(" "), DEFAULT_REVIEW_WAIT_TIMEOUT);
|
|
18
|
+
assertEquals(reviewWaitTimeout("20m"), DEFAULT_REVIEW_WAIT_TIMEOUT); // missing leading P/T
|
|
19
|
+
assertEquals(reviewWaitTimeout("P"), DEFAULT_REVIEW_WAIT_TIMEOUT); // no component
|
|
20
|
+
assertEquals(reviewWaitTimeout("PT"), DEFAULT_REVIEW_WAIT_TIMEOUT); // T with no time component
|
|
21
|
+
assertEquals(reviewWaitTimeout("garbage"), DEFAULT_REVIEW_WAIT_TIMEOUT);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
Deno.test("reviewWaitTimeout: a valid ISO-8601 duration is honoured and upper-cased", () => {
|
|
25
|
+
assertEquals(reviewWaitTimeout("PT30M"), "PT30M");
|
|
26
|
+
assertEquals(reviewWaitTimeout("pt30m"), "PT30M");
|
|
27
|
+
assertEquals(reviewWaitTimeout(" PT1H30M "), "PT1H30M");
|
|
28
|
+
assertEquals(reviewWaitTimeout("PT45S"), "PT45S");
|
|
29
|
+
assertEquals(reviewWaitTimeout("P1D"), "P1D");
|
|
30
|
+
assertEquals(reviewWaitTimeout("P1DT2H"), "P1DT2H");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
Deno.test("reviewWaitTimeout: a custom fallback is used when the value is invalid", () => {
|
|
34
|
+
assertEquals(reviewWaitTimeout("nope", "PT10M"), "PT10M");
|
|
35
|
+
assertEquals(reviewWaitTimeout(undefined, "PT10M"), "PT10M");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
Deno.test("clampNudgeMinutes: blank / absent / non-numeric → fallback", () => {
|
|
39
|
+
assertEquals(clampNudgeMinutes(""), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
40
|
+
assertEquals(clampNudgeMinutes(" "), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
41
|
+
assertEquals(clampNudgeMinutes(undefined), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
42
|
+
assertEquals(clampNudgeMinutes(null), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
43
|
+
assertEquals(clampNudgeMinutes("abc"), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
44
|
+
assertEquals(clampNudgeMinutes(NaN), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
Deno.test("clampNudgeMinutes: a valid positive value (string or number) is honoured", () => {
|
|
48
|
+
assertEquals(clampNudgeMinutes("10"), 10);
|
|
49
|
+
assertEquals(clampNudgeMinutes(15), 15);
|
|
50
|
+
assertEquals(clampNudgeMinutes(" 7 "), 7);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
Deno.test("clampNudgeMinutes: fractional values are truncated", () => {
|
|
54
|
+
assertEquals(clampNudgeMinutes("5.9"), 5);
|
|
55
|
+
assertEquals(clampNudgeMinutes(3.2), 3);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
Deno.test("clampNudgeMinutes: zero and negatives fall back (a 0 cooldown would hammer the API)", () => {
|
|
59
|
+
assertEquals(clampNudgeMinutes(0), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
60
|
+
assertEquals(clampNudgeMinutes("-4"), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
Deno.test("clampNudgeMinutes: above the ceiling is clamped, not rejected", () => {
|
|
64
|
+
assertEquals(clampNudgeMinutes(100_000), MAX_REVIEW_NUDGE_MINUTES);
|
|
65
|
+
assertEquals(clampNudgeMinutes(String(MAX_REVIEW_NUDGE_MINUTES + 1)), MAX_REVIEW_NUDGE_MINUTES);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
Deno.test("clampNudgeMinutes: an oversized fallback is itself clamped to the ceiling", () => {
|
|
69
|
+
assertEquals(clampNudgeMinutes("", MAX_REVIEW_NUDGE_MINUTES + 50), MAX_REVIEW_NUDGE_MINUTES);
|
|
70
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Review-wait liveness policy, kept as a pure module (no env, no I/O) so it is trivially
|
|
2
|
+
// testable — mirrors app/rounds.ts. `service.ts` builds the fleet-wide REVIEW_WAIT_TIMEOUT and
|
|
3
|
+
// REVIEW_NUDGE_MS on top of these, and the process's timer catch is seeded with the validated
|
|
4
|
+
// ISO-8601 duration at submit.
|
|
5
|
+
//
|
|
6
|
+
// Two knobs govern the review-wait watchdog:
|
|
7
|
+
// • the *timeout* — an ISO-8601 duration handed to the process's `wait-review-timeout` timer
|
|
8
|
+
// catch (the far side of the event-based-gateway race against `review-ready`). If no fresh
|
|
9
|
+
// review arrives within it, the loop escalates to a human instead of hanging forever.
|
|
10
|
+
// • the *nudge cooldown* — how long the poller waits between automatic Copilot re-requests for
|
|
11
|
+
// one waiting PR, so a re-request that Copilot dismisses is retried without hammering the API.
|
|
12
|
+
|
|
13
|
+
/** Default review-wait timeout (ISO-8601 duration): how long the loop waits for a fresh review
|
|
14
|
+
* before the timer arm of the event-based gateway fires and it escalates to a human. */
|
|
15
|
+
export const DEFAULT_REVIEW_WAIT_TIMEOUT = "PT20M";
|
|
16
|
+
|
|
17
|
+
// A pragmatic ISO-8601 duration matcher: requires a leading `P`, at least one component, and a
|
|
18
|
+
// `T` before any time components (with at least one time component after it). Good enough to
|
|
19
|
+
// reject an obviously-malformed env value before it is baked into a timer expression the engine
|
|
20
|
+
// would fail to interpret; not a full grammar (we don't need fractional seconds here).
|
|
21
|
+
const ISO_DURATION = /^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/;
|
|
22
|
+
|
|
23
|
+
/** Validate an ISO-8601 duration for the review-wait timer, falling back to `def` when the value
|
|
24
|
+
* is absent, blank, or malformed — a bad env value must never deploy an uninterpretable timer
|
|
25
|
+
* expression into the process. Normalises to upper case (`pt20m` → `PT20M`). */
|
|
26
|
+
export function reviewWaitTimeout(
|
|
27
|
+
raw: string | undefined,
|
|
28
|
+
def: string = DEFAULT_REVIEW_WAIT_TIMEOUT,
|
|
29
|
+
): string {
|
|
30
|
+
const s = (raw ?? "").trim().toUpperCase();
|
|
31
|
+
return s !== "" && ISO_DURATION.test(s) ? s : def;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Default cooldown (minutes) between automatic Copilot re-request nudges for one waiting PR.
|
|
35
|
+
* Kept comfortably below the review-wait timeout default so several nudges are attempted before
|
|
36
|
+
* the loop escalates. */
|
|
37
|
+
export const DEFAULT_REVIEW_NUDGE_MINUTES = 5;
|
|
38
|
+
|
|
39
|
+
/** Upper bound on the nudge cooldown — a runaway value could park a genuinely-stalled PR for days
|
|
40
|
+
* with no re-request. Mirrors the ceiling discipline in rounds.ts. */
|
|
41
|
+
export const MAX_REVIEW_NUDGE_MINUTES = 24 * 60;
|
|
42
|
+
|
|
43
|
+
/** Coerce the operator-supplied nudge cooldown (env `NANO_PR_REVIEW_NUDGE_MINUTES`, minutes) into
|
|
44
|
+
* a sane positive integer. Absent / blank / non-numeric / zero / negative / NaN fall back to
|
|
45
|
+
* `fallback`; values above the ceiling are clamped down (and so is an oversized `fallback`, so it
|
|
46
|
+
* can never bypass the ceiling). */
|
|
47
|
+
export function clampNudgeMinutes(
|
|
48
|
+
value: unknown,
|
|
49
|
+
fallback: number = DEFAULT_REVIEW_NUDGE_MINUTES,
|
|
50
|
+
): number {
|
|
51
|
+
const safeFallback = Number.isFinite(fallback)
|
|
52
|
+
? Math.min(Math.max(Math.trunc(fallback), 1), MAX_REVIEW_NUDGE_MINUTES)
|
|
53
|
+
: DEFAULT_REVIEW_NUDGE_MINUTES;
|
|
54
|
+
const n = typeof value === "number" ? value : Number(String(value ?? "").trim());
|
|
55
|
+
if (!Number.isFinite(n)) return safeFallback;
|
|
56
|
+
const i = Math.trunc(n);
|
|
57
|
+
if (i < 1) return safeFallback;
|
|
58
|
+
return Math.min(i, MAX_REVIEW_NUDGE_MINUTES);
|
|
59
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Contract for clampRounds — the per-submit round-cap coercion. The submit form renders every
|
|
2
|
+
// field as a text input (the runtime hardcodes type="text"), so the override arrives as a string
|
|
3
|
+
// and a blank field must fall back to the fleet default. Run with `deno test`.
|
|
4
|
+
import { assertEquals } from "jsr:@std/assert@1";
|
|
5
|
+
import { clampCiFixBudget, clampRounds, MAX_CI_FIX_CEILING, MAX_ROUNDS_CEILING } from "./rounds.ts";
|
|
6
|
+
|
|
7
|
+
Deno.test("blank / absent / non-numeric → fallback", () => {
|
|
8
|
+
assertEquals(clampRounds("", 20), 20);
|
|
9
|
+
assertEquals(clampRounds(" ", 20), 20);
|
|
10
|
+
assertEquals(clampRounds(undefined, 20), 20);
|
|
11
|
+
assertEquals(clampRounds(null, 20), 20);
|
|
12
|
+
assertEquals(clampRounds("abc", 20), 20);
|
|
13
|
+
assertEquals(clampRounds(NaN, 20), 20);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
Deno.test("a valid positive value (string or number) is honoured", () => {
|
|
17
|
+
assertEquals(clampRounds("5", 20), 5);
|
|
18
|
+
assertEquals(clampRounds(30, 20), 30);
|
|
19
|
+
assertEquals(clampRounds(" 12 ", 20), 12);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
Deno.test("fractional values are truncated to a whole round", () => {
|
|
23
|
+
assertEquals(clampRounds("7.9", 20), 7);
|
|
24
|
+
assertEquals(clampRounds(3.2, 20), 3);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
Deno.test("zero and negatives fall back (a cap < 1 would escalate before round 1)", () => {
|
|
28
|
+
assertEquals(clampRounds(0, 20), 20);
|
|
29
|
+
assertEquals(clampRounds("-4", 20), 20);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
Deno.test("above the ceiling is clamped, not rejected", () => {
|
|
33
|
+
assertEquals(clampRounds(1000, 20), MAX_ROUNDS_CEILING);
|
|
34
|
+
assertEquals(clampRounds(String(MAX_ROUNDS_CEILING + 1), 20), MAX_ROUNDS_CEILING);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
Deno.test("an oversized fallback is itself clamped to the ceiling", () => {
|
|
38
|
+
// A blank/invalid input falls back — but the fallback must not bypass the ceiling.
|
|
39
|
+
assertEquals(clampRounds("", MAX_ROUNDS_CEILING + 50), MAX_ROUNDS_CEILING);
|
|
40
|
+
assertEquals(clampRounds(" ", MAX_ROUNDS_CEILING + 1), MAX_ROUNDS_CEILING);
|
|
41
|
+
assertEquals(clampRounds(0, MAX_ROUNDS_CEILING + 999), MAX_ROUNDS_CEILING);
|
|
42
|
+
assertEquals(clampRounds("abc", MAX_ROUNDS_CEILING + 1), MAX_ROUNDS_CEILING);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// clampCiFixBudget — the merge stage's CI-fix attempt budget (NANO_PR_MAX_CI_FIX_ROUNDS). Unlike
|
|
46
|
+
// clampRounds it ALLOWS 0, because 0 is a meaningful setting: disable auto-fix so a blocked PR
|
|
47
|
+
// escalates to a human immediately.
|
|
48
|
+
Deno.test("ci-fix budget: blank / non-numeric / negative → fallback", () => {
|
|
49
|
+
assertEquals(clampCiFixBudget("", 3), 3);
|
|
50
|
+
assertEquals(clampCiFixBudget(" ", 3), 3);
|
|
51
|
+
assertEquals(clampCiFixBudget(undefined, 3), 3);
|
|
52
|
+
assertEquals(clampCiFixBudget(null, 3), 3);
|
|
53
|
+
assertEquals(clampCiFixBudget("abc", 3), 3);
|
|
54
|
+
assertEquals(clampCiFixBudget(NaN, 3), 3);
|
|
55
|
+
assertEquals(clampCiFixBudget("-1", 3), 3);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
Deno.test("ci-fix budget: 0 is honoured (disables auto-fix), unlike clampRounds", () => {
|
|
59
|
+
assertEquals(clampCiFixBudget(0, 3), 0);
|
|
60
|
+
assertEquals(clampCiFixBudget("0", 3), 0);
|
|
61
|
+
// Contrast: the round cap treats 0 as invalid and falls back.
|
|
62
|
+
assertEquals(clampRounds(0, 3), 3);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
Deno.test("ci-fix budget: a valid positive value is honoured and truncated", () => {
|
|
66
|
+
assertEquals(clampCiFixBudget("5", 3), 5);
|
|
67
|
+
assertEquals(clampCiFixBudget(2, 3), 2);
|
|
68
|
+
assertEquals(clampCiFixBudget("4.8", 3), 4);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
Deno.test("ci-fix budget: above the ceiling is clamped; oversized fallback too", () => {
|
|
72
|
+
assertEquals(clampCiFixBudget(1000, 3), MAX_CI_FIX_CEILING);
|
|
73
|
+
assertEquals(clampCiFixBudget("", MAX_CI_FIX_CEILING + 5), MAX_CI_FIX_CEILING);
|
|
74
|
+
});
|
package/app/rounds.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The per-submit round-cap coercion, kept as a pure module (no env, no I/O) so it is trivially
|
|
2
|
+
// testable — mirrors app/waves.ts. `service.ts` builds the fleet default MAX_ROUNDS on top of it,
|
|
3
|
+
// and the submit form / webhook / start action each pass a caller-supplied override through it.
|
|
4
|
+
|
|
5
|
+
/** Upper bound on the review-round cap. A cap of 0 would escalate before the first round; an
|
|
6
|
+
* unbounded cap could run the agent (and its cost) indefinitely, so overrides are clamped here. */
|
|
7
|
+
export const MAX_ROUNDS_CEILING = 100;
|
|
8
|
+
|
|
9
|
+
/** Coerce an arbitrary caller-supplied round cap into a sane positive integer, falling back to
|
|
10
|
+
* `fallback` when the value is absent, blank, non-numeric, zero/negative, or NaN. The submit form
|
|
11
|
+
* sends strings (the runtime renders every field as text), so this accepts `string | number |
|
|
12
|
+
* unknown`. Values above MAX_ROUNDS_CEILING are clamped down rather than rejected — and the same
|
|
13
|
+
* clamp is applied to `fallback`, so an oversized fallback can never bypass the safety ceiling. */
|
|
14
|
+
export function clampRounds(value: unknown, fallback: number): number {
|
|
15
|
+
const safeFallback = Number.isFinite(fallback)
|
|
16
|
+
? Math.min(Math.max(Math.trunc(fallback), 1), MAX_ROUNDS_CEILING)
|
|
17
|
+
: 1;
|
|
18
|
+
const n = typeof value === "number" ? value : Number(String(value ?? "").trim());
|
|
19
|
+
if (!Number.isFinite(n)) return safeFallback;
|
|
20
|
+
const i = Math.trunc(n);
|
|
21
|
+
if (i < 1) return safeFallback;
|
|
22
|
+
return Math.min(i, MAX_ROUNDS_CEILING);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Ceiling on CI-fix attempts, mirroring MAX_ROUNDS_CEILING — an unbounded budget could dispatch
|
|
26
|
+
* the `senior:fix-ci` agent (and its cost) indefinitely against a stubbornly-red PR. */
|
|
27
|
+
export const MAX_CI_FIX_CEILING = 20;
|
|
28
|
+
|
|
29
|
+
/** Coerce the operator-supplied CI-fix budget (env `NANO_PR_MAX_CI_FIX_ROUNDS`) into a sane
|
|
30
|
+
* non-negative integer. Unlike {@link clampRounds} this ALLOWS 0 — a budget of 0 disables
|
|
31
|
+
* auto-fix so a blocked PR escalates to a human immediately. Absent / blank / non-numeric /
|
|
32
|
+
* negative / NaN fall back to `fallback`; values above the ceiling are clamped down (and so is an
|
|
33
|
+
* oversized `fallback`, so it can never bypass the ceiling). */
|
|
34
|
+
export function clampCiFixBudget(value: unknown, fallback: number): number {
|
|
35
|
+
const safeFallback = Number.isFinite(fallback)
|
|
36
|
+
? Math.min(Math.max(Math.trunc(fallback), 0), MAX_CI_FIX_CEILING)
|
|
37
|
+
: 0;
|
|
38
|
+
// Blank / absent → fallback. This guard is essential BEFORE coercion: Number("") is 0, so a
|
|
39
|
+
// blank env var must not be mistaken for an explicit 0 (which legitimately disables auto-fix).
|
|
40
|
+
if (value === null || value === undefined) return safeFallback;
|
|
41
|
+
const trimmed = typeof value === "number" ? value : String(value).trim();
|
|
42
|
+
if (trimmed === "") return safeFallback;
|
|
43
|
+
const n = typeof trimmed === "number" ? trimmed : Number(trimmed);
|
|
44
|
+
if (!Number.isFinite(n)) return safeFallback;
|
|
45
|
+
const i = Math.trunc(n);
|
|
46
|
+
if (i < 0) return safeFallback;
|
|
47
|
+
return Math.min(i, MAX_CI_FIX_CEILING);
|
|
48
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Red/green regression for re-submit clearing stale open escalations (Magikcraft/nano-bpm
|
|
2
|
+
// #597/#599). When a cancelled/converged PR is re-submitted, `submitPr` re-opens it for a fresh
|
|
3
|
+
// convergence run. Any escalation left `open` by the prior run — plus the denormalised
|
|
4
|
+
// `open_escalation_*` pointer on the PR row — must be cleared, or the answer form resurfaces a
|
|
5
|
+
// dead "(no question provided)" question on the re-opened PR (the same stale-row class the plan
|
|
6
|
+
// loop already guards in `startPlan`). Drives `submitPr` against an in-memory data layer with the
|
|
7
|
+
// GitHub transport forced off so it is hermetic.
|
|
8
|
+
import { assertEquals } from "jsr:@std/assert@1";
|
|
9
|
+
import { submitPr } from "./service.ts";
|
|
10
|
+
|
|
11
|
+
// deno-lint-ignore no-explicit-any
|
|
12
|
+
function memTable(rows: any[], key: string) {
|
|
13
|
+
return {
|
|
14
|
+
// deno-lint-ignore no-explicit-any
|
|
15
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
16
|
+
// deno-lint-ignore no-explicit-any
|
|
17
|
+
find: (q: any) =>
|
|
18
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
19
|
+
// deno-lint-ignore no-explicit-any
|
|
20
|
+
insert: (r: any) => {
|
|
21
|
+
rows.push(r);
|
|
22
|
+
return Promise.resolve(r);
|
|
23
|
+
},
|
|
24
|
+
// deno-lint-ignore no-explicit-any
|
|
25
|
+
update: (k: any, patch: any) => {
|
|
26
|
+
const r = rows.find((x) => x[key] === k);
|
|
27
|
+
if (r) Object.assign(r, patch);
|
|
28
|
+
return Promise.resolve(r);
|
|
29
|
+
},
|
|
30
|
+
// deno-lint-ignore no-explicit-any
|
|
31
|
+
delete: (k: any) => {
|
|
32
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
|
|
33
|
+
return Promise.resolve();
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function withGithubOff(run: () => Promise<void>): Promise<void> {
|
|
39
|
+
const prevMode = Deno.env.get("NANO_PR_GITHUB_TRANSPORT");
|
|
40
|
+
const prevTok = Deno.env.get("GITHUB_TOKEN");
|
|
41
|
+
Deno.env.set("NANO_PR_GITHUB_TRANSPORT", "token"); // no token below -> fetchPrMeta returns null
|
|
42
|
+
Deno.env.delete("GITHUB_TOKEN");
|
|
43
|
+
return run().finally(() => {
|
|
44
|
+
if (prevMode !== undefined) Deno.env.set("NANO_PR_GITHUB_TRANSPORT", prevMode);
|
|
45
|
+
else Deno.env.delete("NANO_PR_GITHUB_TRANSPORT");
|
|
46
|
+
if (prevTok !== undefined) Deno.env.set("GITHUB_TOKEN", prevTok);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
Deno.test("re-submit of a cancelled PR clears stale open escalations + the denormalised pointer", async () => {
|
|
51
|
+
await withGithubOff(async () => {
|
|
52
|
+
const PR_KEY = "owner/repo#42";
|
|
53
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
54
|
+
pull_requests: {
|
|
55
|
+
rows: [{
|
|
56
|
+
pr_key: PR_KEY,
|
|
57
|
+
repo: "owner/repo",
|
|
58
|
+
number: 42,
|
|
59
|
+
url: "https://github.com/owner/repo/pull/42",
|
|
60
|
+
title: "old title",
|
|
61
|
+
status: "abandoned", // terminal -> re-open path
|
|
62
|
+
current_round: 3,
|
|
63
|
+
open_escalation_id: 5,
|
|
64
|
+
open_escalation_question: "(no question provided)",
|
|
65
|
+
}],
|
|
66
|
+
key: "pr_key",
|
|
67
|
+
},
|
|
68
|
+
escalations: {
|
|
69
|
+
rows: [{ id: 5, pr_key: PR_KEY, round_no: 3, kind: "question", question: "(no question provided)", status: "open" }],
|
|
70
|
+
key: "id",
|
|
71
|
+
},
|
|
72
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
73
|
+
};
|
|
74
|
+
const data = {
|
|
75
|
+
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
76
|
+
// deno-lint-ignore no-explicit-any
|
|
77
|
+
} as any;
|
|
78
|
+
const engine = {
|
|
79
|
+
createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }),
|
|
80
|
+
// deno-lint-ignore no-explicit-any
|
|
81
|
+
} as any;
|
|
82
|
+
|
|
83
|
+
await submitPr(data, engine, {
|
|
84
|
+
repo: "owner/repo",
|
|
85
|
+
number: 42,
|
|
86
|
+
url: "https://github.com/owner/repo/pull/42",
|
|
87
|
+
prKey: PR_KEY,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// The prior run's open escalation is retired (not left "open" to resurface a dead form) …
|
|
91
|
+
const esc = stores.escalations.rows[0] as Record<string, unknown>;
|
|
92
|
+
assertEquals(esc.status, "stale");
|
|
93
|
+
// … and the PR row is re-opened with the denormalised escalation pointer cleared.
|
|
94
|
+
const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
|
|
95
|
+
assertEquals(pr.status, "converging");
|
|
96
|
+
assertEquals(pr.current_round, 1);
|
|
97
|
+
assertEquals(pr.open_escalation_id, null);
|
|
98
|
+
assertEquals(pr.open_escalation_question, null);
|
|
99
|
+
assertEquals(pr.process_key, "PI-9");
|
|
100
|
+
});
|
|
101
|
+
});
|