@nanobpm/nano-workforce 0.91.0 → 0.93.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/CHANGELOG.md +19 -0
- package/SPEC.md +10 -4
- package/app/admittedStaging.test.ts +98 -0
- package/app/contracts.ts +10 -1
- package/app/convergeGate.test.ts +80 -1
- package/app/delivery.ts +61 -0
- package/app/epicBucket.test.ts +57 -0
- package/app/epicSetValidation.test.ts +169 -0
- package/app/migration045.test.ts +97 -0
- package/app/plan.ts +401 -12
- package/app/planGateway.test.ts +106 -0
- package/app/scopeGuard.test.ts +147 -0
- package/app/scopeGuard.ts +131 -0
- package/app/service.ts +13 -1
- package/db/migrations/044_plan_list_bucket.sql +32 -0
- package/db/migrations/045_epic_set_admission_staging.sql +56 -0
- package/e2e/support/engine-client.ts +24 -8
- package/openapi.yaml +245 -0
- package/operations/acknowledgeEpic.test.ts +130 -0
- package/operations/acknowledgeEpic.ts +63 -0
- package/operations/startEpicSet.admission.integration.test.ts +572 -0
- package/operations/startEpicSet.ts +217 -0
- package/operations/startPlanFanout.ts +5 -56
- package/package.json +1 -1
- package/pages/epic.page.json +15 -3
- package/pages/overview.page.json +13 -1
- package/resources/prompts/feature.md +30 -1
- package/scripts/pages-contract.test.ts +17 -18
- package/workers/converge-gate/worker.ts +46 -2
- package/workers/record-plan/worker.test.ts +1 -0
- package/workers/record-plan/worker.ts +2 -2
- package/workers/record-results/worker.test.ts +19 -1
- package/workers/record-results/worker.ts +3 -3
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Scope-integrity guard — unit tests for the canonical router (app/scopeGuard.ts) and its parsing
|
|
2
|
+
// helpers.
|
|
3
|
+
//
|
|
4
|
+
// A parity slice can be silently under-delivered: an agent splits a large slice, ships one half,
|
|
5
|
+
// then `Closes #N` a broader-scoped parent while recording the deferred remainder only in PR prose
|
|
6
|
+
// (a `## Scope` section) with no filed follow-up issue. Magikcraft/nano-bpm#631 → PR #863 did
|
|
7
|
+
// exactly this and the deferred half was lost until a human re-filed it as #872. These two guards
|
|
8
|
+
// (#313) block that class: a partial delivery may not close-keyword a broader-scoped parent, and any
|
|
9
|
+
// deferral must link a filed follow-up issue rather than live in prose.
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
12
|
+
import {
|
|
13
|
+
evaluateScopeGuard,
|
|
14
|
+
findClosingKeywordRefs,
|
|
15
|
+
hasDeferralMarker,
|
|
16
|
+
hasFollowupIssueRef,
|
|
17
|
+
} from "./scopeGuard.ts";
|
|
18
|
+
|
|
19
|
+
// ── The canonical router ────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
test("evaluateScopeGuard: a full-scope PR that Closes its parent, no deferral, is allowed", () => {
|
|
22
|
+
const r = evaluateScopeGuard({ prBody: "Implements the feature end to end.\n\nCloses #313" });
|
|
23
|
+
assertEquals(r.scopeBlocked, false);
|
|
24
|
+
assertEquals(r.scopeBlockReason, "");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("evaluateScopeGuard: a plain PR with no closing keyword and no deferral is allowed", () => {
|
|
28
|
+
const r = evaluateScopeGuard({ prBody: "A small refactor. Refs #10" });
|
|
29
|
+
assertEquals(r.scopeBlocked, false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("evaluateScopeGuard: Closes a broader parent AND defers scope → blocked (guard 1)", () => {
|
|
33
|
+
const r = evaluateScopeGuard({
|
|
34
|
+
prBody:
|
|
35
|
+
"Delivers the nested ad-hoc half.\n\n## Scope\nEmbedded SUB_PROCESS tools remain the deferred refinement.\n\nCloses #631",
|
|
36
|
+
});
|
|
37
|
+
assertEquals(r.scopeBlocked, true);
|
|
38
|
+
assertStringIncludes(r.scopeBlockReason, "must not close a broader-scoped issue");
|
|
39
|
+
assertStringIncludes(r.scopeBlockReason, "#631");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("evaluateScopeGuard: defers scope but links NO follow-up issue → blocked (guard 2)", () => {
|
|
43
|
+
const r = evaluateScopeGuard({
|
|
44
|
+
prBody: "Ships the first half.\n\n## Scope\nThe rest is deferred.\n\nRefs #631",
|
|
45
|
+
});
|
|
46
|
+
assertEquals(r.scopeBlocked, true);
|
|
47
|
+
assertStringIncludes(r.scopeBlockReason, "no filed follow-up issue");
|
|
48
|
+
// Guard 1 must NOT fire — this PR correctly used a non-closing ref.
|
|
49
|
+
assert(
|
|
50
|
+
!r.scopeBlockReason.includes("must not close"),
|
|
51
|
+
"a non-closing ref must not trip the closing-keyword guard",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("evaluateScopeGuard: defers scope AND links a filed follow-up AND uses a non-closing ref → allowed", () => {
|
|
56
|
+
const r = evaluateScopeGuard({
|
|
57
|
+
prBody:
|
|
58
|
+
"Ships the first half.\n\n## Scope\nThe embedded SUB_PROCESS half is deferred.\nTracked-in: #872\n\nRefs #631",
|
|
59
|
+
});
|
|
60
|
+
assertEquals(r.scopeBlocked, false);
|
|
61
|
+
assertEquals(r.scopeBlockReason, "");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("evaluateScopeGuard: the motivating incident (Closes #631 + ## Scope + no follow-up) trips BOTH guards", () => {
|
|
65
|
+
const r = evaluateScopeGuard({
|
|
66
|
+
prBody:
|
|
67
|
+
"## Summary\nNested ad-hoc / agent-of-agents delivered.\n\n## Scope\nembedded `SUB_PROCESS` tools whose multi-element body runs by token flow remain the deferred refinement.\n\nCloses #631",
|
|
68
|
+
});
|
|
69
|
+
assertEquals(r.scopeBlocked, true);
|
|
70
|
+
assertStringIncludes(r.scopeBlockReason, "must not close a broader-scoped issue");
|
|
71
|
+
assertStringIncludes(r.scopeBlockReason, "no filed follow-up issue");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("evaluateScopeGuard: a follow-up link alone does not excuse a closing keyword on a split", () => {
|
|
75
|
+
// Even with the remainder tracked, closing the broader parent is still wrong — it reads as done.
|
|
76
|
+
const r = evaluateScopeGuard({
|
|
77
|
+
prBody: "Ships half.\n\nDeferred: the rest. Follow-up: #872\n\nCloses #631",
|
|
78
|
+
});
|
|
79
|
+
assertEquals(r.scopeBlocked, true);
|
|
80
|
+
assertStringIncludes(r.scopeBlockReason, "must not close a broader-scoped issue");
|
|
81
|
+
assert(!r.scopeBlockReason.includes("no filed follow-up issue"), "the follow-up was linked");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("evaluateScopeGuard: tolerates null / empty bodies", () => {
|
|
85
|
+
assertEquals(evaluateScopeGuard({ prBody: null }).scopeBlocked, false);
|
|
86
|
+
assertEquals(evaluateScopeGuard({ prBody: undefined }).scopeBlocked, false);
|
|
87
|
+
assertEquals(evaluateScopeGuard({ prBody: "" }).scopeBlocked, false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── The parsers ─────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
test("findClosingKeywordRefs: extracts bare, cross-repo, and URL closing refs; dedupes", () => {
|
|
93
|
+
const body = [
|
|
94
|
+
"Closes #12",
|
|
95
|
+
"fixes: owner/repo#34",
|
|
96
|
+
"Resolved https://github.com/owner/repo/issues/56",
|
|
97
|
+
"Closes #12", // duplicate
|
|
98
|
+
].join("\n");
|
|
99
|
+
assertEquals(findClosingKeywordRefs(body), [
|
|
100
|
+
"#12",
|
|
101
|
+
"owner/repo#34",
|
|
102
|
+
"https://github.com/owner/repo/issues/56",
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("findClosingKeywordRefs: a non-closing ref (Refs / Part of) is not a closing keyword", () => {
|
|
107
|
+
assertEquals(findClosingKeywordRefs("Refs #12\nPart of #34\nDepends-on: #56"), []);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("hasDeferralMarker: detects a ## Scope heading and deferral phrases; ignores clean prose", () => {
|
|
111
|
+
assert(hasDeferralMarker("## Scope\nfoo"), "a Scope heading defers");
|
|
112
|
+
assert(hasDeferralMarker("### scope of work"), "any heading level counts");
|
|
113
|
+
assert(hasDeferralMarker("The rest is deferred to later."), "'deferred' defers");
|
|
114
|
+
assert(hasDeferralMarker("This is out of scope for now."), "'out of scope' defers");
|
|
115
|
+
assert(hasDeferralMarker("The remainder is left for a follow-up."), "'remainder' defers");
|
|
116
|
+
assert(!hasDeferralMarker("Implements everything. Closes #1."), "clean prose does not defer");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("hasDeferralMarker: a bare 'remain*' without deferral context is not a deferral", () => {
|
|
120
|
+
// "all done" phrasing must not be read as a scope deferral (Copilot advisory,
|
|
121
|
+
// app/scopeGuard.ts:48): a full-scope PR that merely reports nothing outstanding
|
|
122
|
+
// would otherwise be blocked from converging.
|
|
123
|
+
assert(!hasDeferralMarker("No issues remain.\n\nCloses #123"), "'No issues remain' is not a deferral");
|
|
124
|
+
assert(!hasDeferralMarker("All checks remain green."), "'remain green' is not a deferral");
|
|
125
|
+
assert(!hasDeferralMarker("No failing tests remaining. Closes #7"), "'remaining' alone is not a deferral");
|
|
126
|
+
// ...but a remainder mention near genuine deferral context still defers.
|
|
127
|
+
assert(hasDeferralMarker("The remaining scope is tracked separately."), "'remaining' near 'scope' defers");
|
|
128
|
+
assert(hasDeferralMarker("Remaining work is a follow-up."), "'remaining' near 'follow-up' defers");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("hasFollowupIssueRef: only an explicit tracking marker + issue ref counts", () => {
|
|
132
|
+
assert(hasFollowupIssueRef("Deferred-to: #872"), "Deferred-to marker");
|
|
133
|
+
assert(hasFollowupIssueRef("Tracked-in: owner/repo#872"), "cross-repo tracking marker");
|
|
134
|
+
assert(hasFollowupIssueRef("Follow-up: #900"), "Follow-up marker");
|
|
135
|
+
assert(hasFollowupIssueRef("Follow up issue: #900"), "Follow up issue marker");
|
|
136
|
+
assert(!hasFollowupIssueRef("The rest is deferred."), "bare deferral prose is not a filed link");
|
|
137
|
+
assert(!hasFollowupIssueRef("Refs #631"), "a parent ref is not a remainder tracker");
|
|
138
|
+
// A full GitHub issue URL is a valid filed follow-up link, same as the closing-keyword parser accepts.
|
|
139
|
+
assert(
|
|
140
|
+
hasFollowupIssueRef("Deferred-to: https://github.com/owner/repo/issues/872"),
|
|
141
|
+
"Deferred-to marker with a full issue URL",
|
|
142
|
+
);
|
|
143
|
+
assert(
|
|
144
|
+
hasFollowupIssueRef("Follow-up issue: https://github.com/nanobpm/nano-workforce/issues/900"),
|
|
145
|
+
"Follow-up marker with a full issue URL",
|
|
146
|
+
);
|
|
147
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Scope-integrity gate for the review-convergence loop (issue #313).
|
|
2
|
+
//
|
|
3
|
+
// A parity slice can be silently under-delivered: an agent legitimately splits a large slice, ships
|
|
4
|
+
// one half, but then (a) uses a `Closes #N` closing keyword on an issue whose stated scope was
|
|
5
|
+
// broader than what shipped, and (b) records the deferred remainder only in PR/commit prose (a
|
|
6
|
+
// `## Scope` section) rather than as a filed, tracked issue. The parent then reads as fully done —
|
|
7
|
+
// `gh issue list` shows nothing outstanding — and downstream consumers trust "issue closed =
|
|
8
|
+
// capability present". This is exactly how Magikcraft/nano-bpm#631 → PR #863 (`## Scope` deferral,
|
|
9
|
+
// `Closes #631`, no follow-up) lost the deferred half until a human re-filed it by hand as #872.
|
|
10
|
+
//
|
|
11
|
+
// This pure router encodes the two guards proposed in #313, evaluated over the PR description body
|
|
12
|
+
// so the deterministic converge-gate (`workers/converge-gate`) can block a partial delivery from
|
|
13
|
+
// closing a broader-scoped parent, escalating to the human `wait-answer` task instead of merging:
|
|
14
|
+
//
|
|
15
|
+
// 1. Closing-keyword integrity — a PR may only carry `Closes/Fixes/Resolves #N` when it delivers
|
|
16
|
+
// #N's full stated scope. When the same body ALSO defers scope (a `## Scope` section /
|
|
17
|
+
// "deferred" / "out of scope" / "remains"), the closing keyword is flagged: the PR must instead
|
|
18
|
+
// use a non-closing ref (`Refs #N` / `Part of #N`) and leave #N open (or convert #N into a
|
|
19
|
+
// tracking issue).
|
|
20
|
+
// 2. Deferred ⇒ filed issue, not prose — any PR that defers part of its scope must LINK a filed
|
|
21
|
+
// follow-up issue for the remainder (`Deferred-to: #N` / `Tracked-in: #N` / `Follow-up: #N`). A
|
|
22
|
+
// deferral that exists only in commit/ADR/PR text is a drift surface (invisible, unclaimable
|
|
23
|
+
// work); mirror the repo's "no drift surfaces" rule, applied to scope.
|
|
24
|
+
//
|
|
25
|
+
// Both guards fire only when the body actually DEFERS scope, so a full-scope `Closes #N` PR with no
|
|
26
|
+
// deferral prose passes untouched.
|
|
27
|
+
|
|
28
|
+
export interface ScopeGuardInput {
|
|
29
|
+
/** The PR description body (the text `gh pr create --body` set). */
|
|
30
|
+
prBody: string | null | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ScopeGuardResult {
|
|
34
|
+
scopeBlocked: boolean;
|
|
35
|
+
scopeBlockReason: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// GitHub's closing keywords (close/closes/closed, fix/fixes/fixed, resolve/resolves/resolved)
|
|
39
|
+
// followed by an issue ref: a bare `#123`, a cross-repo `owner/repo#123`, or a full issue URL. The
|
|
40
|
+
// keyword and ref may be separated by whitespace and/or a colon (`Closes: #1`, `Closes #1`).
|
|
41
|
+
const CLOSING_KEYWORD =
|
|
42
|
+
/\b(close[sd]?|fix(?:e[sd])?|resolve[sd]?)[\s:]+(?:https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+|(?:[\w.-]+\/[\w.-]+)?#\d+)/gi;
|
|
43
|
+
|
|
44
|
+
// A body DEFERS scope when it carries a `## Scope` (any heading level) section OR names a deferral in
|
|
45
|
+
// prose. A bare `remain*`/`remainder` is deliberately NOT enough on its own — normal "all done"
|
|
46
|
+
// phrasing ("No issues remain.", "no failing tests remaining") uses it without deferring any scope,
|
|
47
|
+
// and flagging that would block a full-scope PR from converging. A remainder mention only defers when
|
|
48
|
+
// a deferral-context term (scope / follow-up / later / to-do / tracking) sits near it; the incident's
|
|
49
|
+
// honest deferral read "…remain the deferred refinement", which the explicit `defer*` branch catches.
|
|
50
|
+
const DEFERRAL_HEADING = /^#{1,6}\s+scope\b/im;
|
|
51
|
+
const DEFERRAL_PHRASE = /\bdefer(?:s|red|ral|ring)?\b|\bout[- ]of[- ]scope\b/i;
|
|
52
|
+
const REMAINDER_WORD = /\bremain(?:s|der|ing)?\b/gi;
|
|
53
|
+
const REMAINDER_CONTEXT = /\b(?:scope|follow[- ]?ups?|later|to[- ]?dos?|track(?:s|ed|ing)?|next[- ]steps?)\b/i;
|
|
54
|
+
const REMAINDER_WINDOW = 48;
|
|
55
|
+
|
|
56
|
+
// Whether any `remain*` mention sits within a short window of a deferral-context term.
|
|
57
|
+
function remainderDefersScope(text: string): boolean {
|
|
58
|
+
for (const m of text.matchAll(REMAINDER_WORD)) {
|
|
59
|
+
const idx = m.index ?? 0;
|
|
60
|
+
const window = text.slice(Math.max(0, idx - REMAINDER_WINDOW), idx + m[0].length + REMAINDER_WINDOW);
|
|
61
|
+
if (REMAINDER_CONTEXT.test(window)) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A FILED follow-up issue link for the deferred remainder: an explicit tracking marker followed by
|
|
69
|
+
// an issue ref — a bare `#123`, a cross-repo `owner/repo#123`, or a full issue URL (matching the
|
|
70
|
+
// closing-keyword parser, so an explicit `https://github.com/<owner>/<repo>/issues/<n>` link counts
|
|
71
|
+
// as a tracked follow-up rather than being mistaken for untracked prose). This is the
|
|
72
|
+
// machine-checkable contract feature.md asks split slices to emit.
|
|
73
|
+
const FOLLOWUP_MARKER =
|
|
74
|
+
/\b(?:deferred[- ]to|tracked[- ]in|tracking issue|follow[- ]?ups?(?:\s+issue)?)\b[\s:]*(?:https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+|(?:[\w.-]+\/[\w.-]+)?#\d+)/i;
|
|
75
|
+
|
|
76
|
+
/** The distinct issue refs a body closes via a GitHub closing keyword, in first-seen order. */
|
|
77
|
+
export function findClosingKeywordRefs(body: string | null | undefined): string[] {
|
|
78
|
+
const text = body ?? "";
|
|
79
|
+
const refs: string[] = [];
|
|
80
|
+
const seen = new Set<string>();
|
|
81
|
+
for (const m of text.matchAll(CLOSING_KEYWORD)) {
|
|
82
|
+
const ref = m[0].slice(m[1].length).replace(/^[\s:]+/, "").trim();
|
|
83
|
+
if (!seen.has(ref)) {
|
|
84
|
+
seen.add(ref);
|
|
85
|
+
refs.push(ref);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return refs;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Whether the body defers part of its scope (a `## Scope` section or a deferral phrase). */
|
|
92
|
+
export function hasDeferralMarker(body: string | null | undefined): boolean {
|
|
93
|
+
const text = body ?? "";
|
|
94
|
+
return DEFERRAL_HEADING.test(text) || DEFERRAL_PHRASE.test(text) || remainderDefersScope(text);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Whether the body links a filed follow-up issue for the deferred remainder. */
|
|
98
|
+
export function hasFollowupIssueRef(body: string | null | undefined): boolean {
|
|
99
|
+
return FOLLOWUP_MARKER.test(body ?? "");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Decide whether a PR's scope framing is safe to converge/merge. Pure; the worker feeds it the
|
|
103
|
+
* live PR body and fails CLOSED (blocks) when that body cannot be read. */
|
|
104
|
+
export function evaluateScopeGuard(input: ScopeGuardInput): ScopeGuardResult {
|
|
105
|
+
const body = input.prBody ?? "";
|
|
106
|
+
const defers = hasDeferralMarker(body);
|
|
107
|
+
const reasons: string[] = [];
|
|
108
|
+
|
|
109
|
+
if (defers) {
|
|
110
|
+
const closing = findClosingKeywordRefs(body);
|
|
111
|
+
if (closing.length > 0) {
|
|
112
|
+
const noun = closing.length === 1 ? "issue" : "issues";
|
|
113
|
+
reasons.push(
|
|
114
|
+
`this PR defers part of its scope yet closing-keywords ${noun} ${closing.join(", ")} — a partial delivery must not close a broader-scoped issue; use a non-closing ref (Refs #N / Part of #N) and leave it open (or convert it into a tracking issue)`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (!hasFollowupIssueRef(body)) {
|
|
118
|
+
reasons.push(
|
|
119
|
+
"this PR defers part of its scope but links no filed follow-up issue for the remainder — file a tracking issue for each deferred item and link it (Deferred-to: #N / Tracked-in: #N / Follow-up: #N) so the remainder is tracked, not left in PR prose",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (reasons.length === 0) {
|
|
125
|
+
return { scopeBlocked: false, scopeBlockReason: "" };
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
scopeBlocked: true,
|
|
129
|
+
scopeBlockReason: `Scope integrity blocked: ${reasons.join("; ")}.`,
|
|
130
|
+
};
|
|
131
|
+
}
|
package/app/service.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { pollLineage } from "./lineage.ts";
|
|
|
33
33
|
import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
34
34
|
import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
|
|
35
35
|
import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
|
|
36
|
-
import { planReviews, plans, planTaskDeps, planTasks } from "./plan.ts";
|
|
36
|
+
import { backfillPlanBuckets, planReviews, plans, planTaskDeps, planTasks } from "./plan.ts";
|
|
37
37
|
import { derivePromotionState, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
|
|
38
38
|
import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
|
|
39
39
|
import { trialMergeAudits } from "./trialMerge.ts";
|
|
@@ -1773,6 +1773,11 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1773
1773
|
* on every poll. */
|
|
1774
1774
|
let featureStagesBackfilled = false;
|
|
1775
1775
|
|
|
1776
|
+
/** One-shot guard so the epic-bucket backfill (`backfillPlanBuckets`, #298) runs at most once per
|
|
1777
|
+
* process, on the first `pollOnce` — re-projecting pre-migration-042 `plans` rows whose
|
|
1778
|
+
* `list_bucket` is still NULL. The gateway keeps every future write fresh; idempotent regardless. */
|
|
1779
|
+
let planBucketsBackfilled = false;
|
|
1780
|
+
|
|
1776
1781
|
export async function pollOnce(
|
|
1777
1782
|
data: DataLayer,
|
|
1778
1783
|
engine: EngineClient,
|
|
@@ -1789,6 +1794,13 @@ export async function pollOnce(
|
|
|
1789
1794
|
await backfillFeatureStages(data);
|
|
1790
1795
|
featureStagesBackfilled = true;
|
|
1791
1796
|
}
|
|
1797
|
+
// One-shot: re-project any pre-#298 `plans` rows whose `list_bucket` is still NULL, so a legacy
|
|
1798
|
+
// epic buckets correctly into Active/History from the first pass. Guard armed only after success so
|
|
1799
|
+
// a transient failure retries next pass (mirrors the feature-stage backfill above).
|
|
1800
|
+
if (!planBucketsBackfilled) {
|
|
1801
|
+
await backfillPlanBuckets(data);
|
|
1802
|
+
planBucketsBackfilled = true;
|
|
1803
|
+
}
|
|
1792
1804
|
await pollReviews(data, engine, token);
|
|
1793
1805
|
await pollMerges(data, engine, token);
|
|
1794
1806
|
await pollDelivery(data);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-- 044_plan_list_bucket.sql — issue #298: bucket EPICS on the derived `delivery` rollup, not raw
|
|
2
|
+
-- `plan.status`, so a `done` epic whose slice PRs are still CONVERGING — or one that has fully LANDED
|
|
3
|
+
-- but still needs its integration→main promotion PR — does NOT silently vanish from the Active epic
|
|
4
|
+
-- lists the instant `status = done`. Mirrors the feature-run Active/History tick-off partition
|
|
5
|
+
-- (038/039 `feature_runs`): reify the partition as a derived, write-time-projected column so the
|
|
6
|
+
-- declarative epic/overview page tabs can filter with only stored `{"field":…}` `in` clauses (the
|
|
7
|
+
-- dataGrid page DSL has no OR / IS NULL), and give epics the same operator "Dismiss" affordance
|
|
8
|
+
-- feature runs already have.
|
|
9
|
+
--
|
|
10
|
+
-- Columns (all maintained by the `plans` gateway — app/plan.ts — from the pure `deriveEpicBucket` /
|
|
11
|
+
-- `epicIsAcknowledgeable` helpers in app/delivery.ts on every write, never hand-derived in SQL, the
|
|
12
|
+
-- page, or a poller):
|
|
13
|
+
-- • acknowledged_at — NULL until an operator dismisses a RESOLVED epic (acknowledge-epic) — landed
|
|
14
|
+
-- or resolved-not-landed (`delivery=null`); only still-`converging` epics are
|
|
15
|
+
-- rejected. The twin of feature_runs.acknowledged_at (039).
|
|
16
|
+
-- • list_bucket — 'active' | 'history': deriveEpicBucket(status, delivery, acknowledged_at).
|
|
17
|
+
-- Active = live epics (planning/dispatched) + `done` epics not yet acknowledged
|
|
18
|
+
-- (still converging, landed-but-unpromoted, or resolved-not-landed); History =
|
|
19
|
+
-- acknowledged `done` epics and terminal failed/abandoned epics.
|
|
20
|
+
-- • ack_open — 1 | 0: 1 iff the epic is a RESOLVED (`done`, not `converging`) but
|
|
21
|
+
-- unacknowledged epic — the Active states that carry the Dismiss affordance — so
|
|
22
|
+
-- the page's `showWhenField` gates the button precisely. Mirrors
|
|
23
|
+
-- feature_runs.escalation_open (040).
|
|
24
|
+
--
|
|
25
|
+
-- Forward-only, additive (expand): all nullable with no default, so pre-#298 rows grandfather in as
|
|
26
|
+
-- NULL and never gate control flow. `backfillPlanBuckets` (app/plan.ts) stamps legacy rows once at
|
|
27
|
+
-- boot, and the gateway keeps every future write fresh. Numbered after the current highest prefix on
|
|
28
|
+
-- origin/main (041); the runner wraps each file in its own transaction, so this file must NOT contain
|
|
29
|
+
-- BEGIN/COMMIT.
|
|
30
|
+
ALTER TABLE plans ADD COLUMN acknowledged_at TEXT;
|
|
31
|
+
ALTER TABLE plans ADD COLUMN list_bucket TEXT;
|
|
32
|
+
ALTER TABLE plans ADD COLUMN ack_open INTEGER;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
-- 045_epic_set_admission_staging.sql — issue #292 slice S2: durable ADMISSION STAGING for the
|
|
2
|
+
-- set/batch door (`startEpicSet`).
|
|
3
|
+
--
|
|
4
|
+
-- S2 is the admission DOOR + DAG validator only; it deliberately does NOT start any epic and does
|
|
5
|
+
-- NOT materialize the durable plan graph. Slice S3 (planner lowering: schedule roots, seed the
|
|
6
|
+
-- capability gate, bind the resolved version) is the slice that actually CREATES `plans` rows and
|
|
7
|
+
-- their `plan_deps` edges — so S3, not S2, is the correct owner of both.
|
|
8
|
+
--
|
|
9
|
+
-- That split leaves S2 needing to persist WHAT it admitted so a crash between admission and lowering
|
|
10
|
+
-- does not lose the set. It cannot write `plan_deps` for that: `plan_deps.plan_key REFERENCES
|
|
11
|
+
-- plans(plan_key)` (041), but S2 has not created any `plans` row, so a first-time set submission
|
|
12
|
+
-- would FK-fail (500). Nor should it pre-create a `plans` row — a non-terminal `plans` row reads as
|
|
13
|
+
-- `alreadyRunning` to the canonical `startPlan`, which would wedge S3 from ever starting it.
|
|
14
|
+
--
|
|
15
|
+
-- So S2 stages into its OWN, FK-FREE structure here, and S3 reads it during lowering to materialize
|
|
16
|
+
-- `plans` + `plan_deps` when it schedules roots (where the FK is satisfied by construction). Neither
|
|
17
|
+
-- staging table references `plans` — that is the whole point: the staging is writable BEFORE any
|
|
18
|
+
-- plan graph exists.
|
|
19
|
+
--
|
|
20
|
+
-- • admitted_epics — one row per admitted epic in the set (INCLUDING roots, which carry no edge).
|
|
21
|
+
-- `plan_key` is the epic's canonical key; the rest is what S3 needs to materialize the `plans`
|
|
22
|
+
-- row (repo, issue number/url, the normalized integration base branch). PRIMARY KEY (plan_key)
|
|
23
|
+
-- makes a re-submitted set idempotent (one staged row per epic).
|
|
24
|
+
-- • admitted_plan_deps — the FK-FREE staging twin of `plan_deps`: one row per validated inter-epic
|
|
25
|
+
-- edge (`plan_key` waits for `depends_on_plan_key`, gated by { package, capability_ref }).
|
|
26
|
+
-- Constraints MIRROR plan_deps EXCEPT the FK: PRIMARY KEY (plan_key, depends_on_plan_key) so a
|
|
27
|
+
-- re-submitted set cannot duplicate an edge, CHECK (plan_key <> depends_on_plan_key) so no
|
|
28
|
+
-- self-edge — but NO `REFERENCES plans(...)`, since neither endpoint's plan row exists yet. The
|
|
29
|
+
-- set validator (S2) is what guarantees every endpoint names a submitted epic.
|
|
30
|
+
--
|
|
31
|
+
-- Numbered after the current highest prefix on origin/main (042_plan_promotion.sql) — the branch
|
|
32
|
+
-- forks at 041 while main advanced to 042, so this MUST be 043 to avoid a merge-time prefix
|
|
33
|
+
-- collision. The runner wraps each file in its own transaction, so this file must NOT contain
|
|
34
|
+
-- BEGIN/COMMIT.
|
|
35
|
+
|
|
36
|
+
CREATE TABLE admitted_epics (
|
|
37
|
+
plan_key TEXT NOT NULL PRIMARY KEY, -- the admitted epic's canonical key (owner/repo#123)
|
|
38
|
+
repo TEXT NOT NULL, -- owner/repo the epic issue lives in
|
|
39
|
+
issue_number INTEGER NOT NULL, -- the epic issue number
|
|
40
|
+
issue_url TEXT NOT NULL, -- canonical issue URL (for S3 to materialize the plans row)
|
|
41
|
+
base_branch TEXT NOT NULL, -- normalized integration base branch admitPlan resolved
|
|
42
|
+
created_at TEXT NOT NULL
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
CREATE TABLE admitted_plan_deps (
|
|
46
|
+
plan_key TEXT NOT NULL, -- dependent/consumer epic that waits (FK-free: plans may not exist yet)
|
|
47
|
+
depends_on_plan_key TEXT NOT NULL, -- producer epic it waits for
|
|
48
|
+
package TEXT NOT NULL, -- producer's published package name
|
|
49
|
+
capability_ref TEXT NOT NULL, -- producer epic issue handle → pkg@version
|
|
50
|
+
created_at TEXT NOT NULL,
|
|
51
|
+
PRIMARY KEY (plan_key, depends_on_plan_key),
|
|
52
|
+
CHECK (plan_key <> depends_on_plan_key)
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
CREATE INDEX idx_admitted_plan_deps_plan ON admitted_plan_deps(plan_key);
|
|
56
|
+
CREATE INDEX idx_admitted_plan_deps_producer ON admitted_plan_deps(depends_on_plan_key);
|
|
@@ -1,17 +1,33 @@
|
|
|
1
1
|
import type { EngineClient } from "@nanobpm/urban";
|
|
2
2
|
import type { TestApp } from "@nanobpm/urban-testkit";
|
|
3
3
|
|
|
4
|
-
// urban
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
4
|
+
// `@nanobpm/urban-testkit`'s `WasmEngineClient` has historically lagged urban's `EngineClient`
|
|
5
|
+
// interface: a method lands on the real seam a release or two before the testkit fake grows it. Both
|
|
6
|
+
// guards below defensively COMPLETE the contract for any method the *installed* testkit hasn't
|
|
7
|
+
// implemented yet — each is an idempotent `if (typeof … !== "function")`, so it no-ops the moment a
|
|
8
|
+
// testkit release ships the real method (no version pins to drift). Scoped to the test harness;
|
|
9
|
+
// production adapters (`SdkEngineClient`) implement both for real.
|
|
10
|
+
//
|
|
11
|
+
// - `getForm` (urban 0.49.0, ADR 0062): the original instance of this lag. Now implemented by the
|
|
12
|
+
// currently pinned testkit, so this guard is a no-op there; kept as defence against version skew.
|
|
13
|
+
// These hermetic flows drive completion directly and never resolve a form schema, so the fallback
|
|
14
|
+
// returns `null` — the documented "no matching form" path.
|
|
15
|
+
// - `openUserTasks` (issue #294 moved the pollers onto it): the CURRENT gap — the pinned testkit has
|
|
16
|
+
// `searchUserTasks` but not the open-task-scoped `openUserTasks`, so a poller call throws
|
|
17
|
+
// `not a function` (swallowed by the poller's try/catch) and the read-model denormalisation
|
|
18
|
+
// silently no-ops. Polyfill it as `searchUserTasks({ state: "CREATED" })` — byte-for-byte what
|
|
19
|
+
// urban's real `SdkEngineClient.openUserTasks` does, so the two cannot drift. Fix: nano-workforce
|
|
20
|
+
// #309; categorical (testkit) fix upstream in nanobpm/nano-ide#341.
|
|
11
21
|
export function asEngineClient(engine: TestApp["engine"]): EngineClient {
|
|
12
|
-
const e = engine as unknown as EngineClient & {
|
|
22
|
+
const e = engine as unknown as EngineClient & {
|
|
23
|
+
getForm?: EngineClient["getForm"];
|
|
24
|
+
openUserTasks?: EngineClient["openUserTasks"];
|
|
25
|
+
};
|
|
13
26
|
if (typeof e.getForm !== "function") {
|
|
14
27
|
e.getForm = async () => null;
|
|
15
28
|
}
|
|
29
|
+
if (typeof e.openUserTasks !== "function") {
|
|
30
|
+
e.openUserTasks = (filter) => e.searchUserTasks({ ...filter, state: "CREATED" });
|
|
31
|
+
}
|
|
16
32
|
return e;
|
|
17
33
|
}
|