@bongos/core 1.19.895 → 1.19.896
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/.bongos-core.json +24 -14
- package/docs/module-api-changelog.md +2 -0
- package/migrations/core_250_cascade_requests_do_not_auto_promote.sql +126 -0
- package/modules/lifecycle/db-tasks.js +28 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/promotion_sweep.mjs +40 -0
- package/tests/promotion_triggers_skip_cascade.mjs +117 -0
- package/tests/task_unblocked_open_blocker.mjs +15 -3
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.896",
|
|
6
|
+
"core_contract": "1.19.896",
|
|
7
|
+
"source_commit": "bcf7936c9c65f2b945caeb9c1f6fb3d11138d340",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-20T00:11:11.958Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 498,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2276,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2800,
|
|
20
|
+
"tree_sha256": "2d40ce33d5827af7ceac90c039e54216dd6edccda58e072dd13220ac1d9c587b",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2612,7 +2612,7 @@
|
|
|
2612
2612
|
{
|
|
2613
2613
|
"path": "docs/module-api-changelog.md",
|
|
2614
2614
|
"mode": "0000644",
|
|
2615
|
-
"sha256": "
|
|
2615
|
+
"sha256": "8817c3a21981fb8a7b8a771fd7041b2c0fe086873aefc4128184f12c503bfb77"
|
|
2616
2616
|
},
|
|
2617
2617
|
{
|
|
2618
2618
|
"path": "docs/modules-contract.md",
|
|
@@ -3709,6 +3709,11 @@
|
|
|
3709
3709
|
"mode": "0000644",
|
|
3710
3710
|
"sha256": "ae3e405c7e59fbc13e23ae2a4ba945176f2369a150c997d9b8134e49991f273b"
|
|
3711
3711
|
},
|
|
3712
|
+
{
|
|
3713
|
+
"path": "migrations/core_250_cascade_requests_do_not_auto_promote.sql",
|
|
3714
|
+
"mode": "0000644",
|
|
3715
|
+
"sha256": "599449a48b3850eb457a34630a7bd5d95d354602f1a9e9b887593183a457cd21"
|
|
3716
|
+
},
|
|
3712
3717
|
{
|
|
3713
3718
|
"path": "modules/agents/CLAUDE.md",
|
|
3714
3719
|
"mode": "0000644",
|
|
@@ -5477,7 +5482,7 @@
|
|
|
5477
5482
|
{
|
|
5478
5483
|
"path": "modules/lifecycle/db-tasks.js",
|
|
5479
5484
|
"mode": "0000644",
|
|
5480
|
-
"sha256": "
|
|
5485
|
+
"sha256": "ba8a41a33d3612f41509d50c65533f169b4008bbc8a01df783dbd03fe872afa4"
|
|
5481
5486
|
},
|
|
5482
5487
|
{
|
|
5483
5488
|
"path": "modules/lifecycle/db-versions.js",
|
|
@@ -7717,12 +7722,12 @@
|
|
|
7717
7722
|
{
|
|
7718
7723
|
"path": "package-lock.json",
|
|
7719
7724
|
"mode": "0000644",
|
|
7720
|
-
"sha256": "
|
|
7725
|
+
"sha256": "367843e43813dc8865d06e51ccfeed4c44deb8da04dc4328a5f28a8305cba264"
|
|
7721
7726
|
},
|
|
7722
7727
|
{
|
|
7723
7728
|
"path": "package.json",
|
|
7724
7729
|
"mode": "0000644",
|
|
7725
|
-
"sha256": "
|
|
7730
|
+
"sha256": "7fb341305fc64634b77558a93b0788a4a98ec658acebea090e1299740d2d2f27"
|
|
7726
7731
|
},
|
|
7727
7732
|
{
|
|
7728
7733
|
"path": "public-docs/index.html",
|
|
@@ -9727,7 +9732,7 @@
|
|
|
9727
9732
|
{
|
|
9728
9733
|
"path": "src/module-api.js",
|
|
9729
9734
|
"mode": "0000644",
|
|
9730
|
-
"sha256": "
|
|
9735
|
+
"sha256": "99326b732eb82a5c9bb26e0ec54f196378a4a4f89e647aebb1e32c9d2d52d4eb"
|
|
9731
9736
|
},
|
|
9732
9737
|
{
|
|
9733
9738
|
"path": "src/module-loader/catalog.js",
|
|
@@ -12672,7 +12677,12 @@
|
|
|
12672
12677
|
{
|
|
12673
12678
|
"path": "tests/promotion_sweep.mjs",
|
|
12674
12679
|
"mode": "0000644",
|
|
12675
|
-
"sha256": "
|
|
12680
|
+
"sha256": "c0c88682ef2b43ae582cfe41ac39e0213b50aaf0cae2e9cbd314007a7aa64b89"
|
|
12681
|
+
},
|
|
12682
|
+
{
|
|
12683
|
+
"path": "tests/promotion_triggers_skip_cascade.mjs",
|
|
12684
|
+
"mode": "0000644",
|
|
12685
|
+
"sha256": "ce1de49cfe554f3e5fa4ae9a784a8ba41d537571c9ae6307f72c2710e693f6aa"
|
|
12676
12686
|
},
|
|
12677
12687
|
{
|
|
12678
12688
|
"path": "tests/prose_edits.mjs",
|
|
@@ -13722,7 +13732,7 @@
|
|
|
13722
13732
|
{
|
|
13723
13733
|
"path": "tests/task_unblocked_open_blocker.mjs",
|
|
13724
13734
|
"mode": "0000644",
|
|
13725
|
-
"sha256": "
|
|
13735
|
+
"sha256": "503692cc227e43292c133abba0fe17307fedd4b32be56e457a690b7be88c1586"
|
|
13726
13736
|
},
|
|
13727
13737
|
{
|
|
13728
13738
|
"path": "tests/task_visual_route_gate.mjs",
|
|
@@ -2264,5 +2264,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2264
2264
|
landed since 1.19.893 with no explicit bump. run 35473999345. (task 1002620)
|
|
2265
2265
|
1.19.895 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2266
2266
|
landed since 1.19.894 with no explicit bump. run 35475074265. (task 1002620)
|
|
2267
|
+
1.19.896 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2268
|
+
landed since 1.19.895 with no explicit bump. run 35478072367. (task 1002620)
|
|
2267
2269
|
---------------------------------------------------------------------------
|
|
2268
2270
|
```
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
-- core_250 a cascade REQUEST is never auto-promoted out of backlog
|
|
2
|
+
-- Applied under task 1004077.
|
|
3
|
+
--
|
|
4
|
+
-- WHY. 'backlog' means two different things and only one of them is drainable
|
|
5
|
+
-- by a machine:
|
|
6
|
+
--
|
|
7
|
+
-- waiting on a TRIGGER a dependency has not shipped -- mechanically
|
|
8
|
+
-- unblockable, and what these triggers are for
|
|
9
|
+
-- waiting on a PERSON a generated REQUEST nobody has triaged
|
|
10
|
+
--
|
|
11
|
+
-- modules/lifecycle/cascade.js files every task it generates at 'backlog' and
|
|
12
|
+
-- never 'ready' (one of the four properties tests/cascade.mjs pins), and writes
|
|
13
|
+
-- a preamble onto each one telling the reader it "is a REQUEST, filed at
|
|
14
|
+
-- backlog, and it goes through the board's ordinary triage". Nothing enforced
|
|
15
|
+
-- that downstream, so the board's ordinary triage was skipped by a machine.
|
|
16
|
+
--
|
|
17
|
+
-- WHAT IT COST. modules/hall-ui/public/studio.js reads the artist's queue as
|
|
18
|
+
-- `?discipline=artist&status=backlog`. A cascade task carries no dependencies,
|
|
19
|
+
-- so task_is_fully_unblocked() is trivially true and it qualified for promotion
|
|
20
|
+
-- the instant it existed. Task 1004037 was filed at 19:53:57 and promoted at
|
|
21
|
+
-- 19:54:54 -- 57 seconds, no person involved. Every artist review was swept out
|
|
22
|
+
-- of the artist's room about a minute after arriving and reappeared in
|
|
23
|
+
-- /builder-start as ordinary claimable work; all four open on 2026-09-19 had
|
|
24
|
+
-- been drained. One was then claimed into a git worktree and judged by a grader
|
|
25
|
+
-- panel whose four lenses explicitly do not read a sentence (ADR 0298): an
|
|
26
|
+
-- artist review carried out without an artist, while the studio sat empty.
|
|
27
|
+
--
|
|
28
|
+
-- WHY HERE AS WELL AS IN THE SWEEP. The report-only sweep in
|
|
29
|
+
-- modules/lifecycle/db-tasks.js (PROMOTABLE_BACKLOG_WHERE) is fixed in the same
|
|
30
|
+
-- task, but it is not the only promoter. Each trigger below matches a
|
|
31
|
+
-- dependency edge that may be anchored at the task OR AT ITS GOAL
|
|
32
|
+
-- (`d.from_kind = 'goal' AND d.from_id = t.goal_id`), and a cascade review
|
|
33
|
+
-- INHERITS the reviewed task's goal (cascade.js property 3). So a goal-level
|
|
34
|
+
-- edge promotes a cascade row that has no dependencies of its own. Fixing only
|
|
35
|
+
-- the sweep would leave that path open and the defect would return
|
|
36
|
+
-- intermittently, which is worse than leaving it alone.
|
|
37
|
+
--
|
|
38
|
+
-- SHAPE. `AND t.source IS DISTINCT FROM 'cascade'` sits beside the existing
|
|
39
|
+
-- `AND t.kind <> 'spike'`, which is the established precedent in these same
|
|
40
|
+
-- functions for "this sort of row does not auto-promote". IS DISTINCT FROM, not
|
|
41
|
+
-- <>, because source is nullable and `NULL <> 'cascade'` is NULL, which would
|
|
42
|
+
-- silently stop promoting every task that has no source at all.
|
|
43
|
+
--
|
|
44
|
+
-- NOT task_is_fully_unblocked(). That predicate answers "are this task's
|
|
45
|
+
-- dependencies satisfied", it is shared with the claim gate, and teaching it to
|
|
46
|
+
-- also mean "has a human triaged this" would change what a refused claim means.
|
|
47
|
+
--
|
|
48
|
+
-- Forward-safe: CREATE OR REPLACE only, no DROP, no column change, and the
|
|
49
|
+
-- bodies are 163's verbatim apart from the one added line. A rolled-back code
|
|
50
|
+
-- pin runs fine against these -- the clause narrows what promotes and an older
|
|
51
|
+
-- release neither reads nor writes `tasks.source` on this path.
|
|
52
|
+
|
|
53
|
+
-- (a) a task shipped
|
|
54
|
+
CREATE OR REPLACE FUNCTION promote_dependents_on_ship()
|
|
55
|
+
RETURNS TRIGGER AS $$
|
|
56
|
+
BEGIN
|
|
57
|
+
IF NEW.status = 'shipped' AND (OLD.status IS DISTINCT FROM 'shipped') THEN
|
|
58
|
+
UPDATE tasks t
|
|
59
|
+
SET status = 'ready', promoted_at = COALESCE(t.promoted_at, now()), updated_at = now()
|
|
60
|
+
WHERE t.status = 'backlog'
|
|
61
|
+
AND t.kind <> 'spike'
|
|
62
|
+
AND t.source IS DISTINCT FROM 'cascade'
|
|
63
|
+
AND EXISTS (
|
|
64
|
+
SELECT 1 FROM dependencies d
|
|
65
|
+
WHERE d.to_kind = 'task' AND d.to_id = NEW.id
|
|
66
|
+
AND ( (d.from_kind = 'task' AND d.from_id = t.id)
|
|
67
|
+
OR (d.from_kind = 'goal' AND d.from_id = t.goal_id)
|
|
68
|
+
OR (d.from_kind = 'criterion' AND d.from_id IN (
|
|
69
|
+
SELECT tc.criterion_id FROM task_criteria tc WHERE tc.task_id = t.id)) )
|
|
70
|
+
)
|
|
71
|
+
AND task_is_fully_unblocked(t.id);
|
|
72
|
+
END IF;
|
|
73
|
+
RETURN NEW;
|
|
74
|
+
END;
|
|
75
|
+
$$ LANGUAGE plpgsql;
|
|
76
|
+
|
|
77
|
+
-- (b) a criterion satisfied
|
|
78
|
+
CREATE OR REPLACE FUNCTION promote_dependents_on_criterion_satisfy()
|
|
79
|
+
RETURNS TRIGGER AS $$
|
|
80
|
+
BEGIN
|
|
81
|
+
IF NEW.satisfied = true AND (OLD.satisfied IS DISTINCT FROM true) THEN
|
|
82
|
+
UPDATE tasks t
|
|
83
|
+
SET status = 'ready', promoted_at = COALESCE(t.promoted_at, now()), updated_at = now()
|
|
84
|
+
WHERE t.status = 'backlog'
|
|
85
|
+
AND t.kind <> 'spike'
|
|
86
|
+
AND t.source IS DISTINCT FROM 'cascade'
|
|
87
|
+
AND EXISTS (
|
|
88
|
+
SELECT 1 FROM dependencies d
|
|
89
|
+
WHERE d.to_kind = 'criterion' AND d.to_id = NEW.id
|
|
90
|
+
AND ( (d.from_kind = 'task' AND d.from_id = t.id)
|
|
91
|
+
OR (d.from_kind = 'goal' AND d.from_id = t.goal_id)
|
|
92
|
+
OR (d.from_kind = 'criterion' AND d.from_id IN (
|
|
93
|
+
SELECT tc.criterion_id FROM task_criteria tc WHERE tc.task_id = t.id)) )
|
|
94
|
+
)
|
|
95
|
+
AND task_is_fully_unblocked(t.id);
|
|
96
|
+
END IF;
|
|
97
|
+
RETURN NEW;
|
|
98
|
+
END;
|
|
99
|
+
$$ LANGUAGE plpgsql;
|
|
100
|
+
|
|
101
|
+
-- (c) a goal achieved
|
|
102
|
+
CREATE OR REPLACE FUNCTION promote_dependents_on_goal_achieve()
|
|
103
|
+
RETURNS TRIGGER AS $$
|
|
104
|
+
BEGIN
|
|
105
|
+
IF NEW.status = 'achieved' AND (OLD.status IS DISTINCT FROM 'achieved') THEN
|
|
106
|
+
UPDATE tasks t
|
|
107
|
+
SET status = 'ready', promoted_at = COALESCE(t.promoted_at, now()), updated_at = now()
|
|
108
|
+
WHERE t.status = 'backlog'
|
|
109
|
+
AND t.kind <> 'spike'
|
|
110
|
+
AND t.source IS DISTINCT FROM 'cascade'
|
|
111
|
+
AND EXISTS (
|
|
112
|
+
SELECT 1 FROM dependencies d
|
|
113
|
+
WHERE d.to_kind = 'goal' AND d.to_id = NEW.id
|
|
114
|
+
AND ( (d.from_kind = 'task' AND d.from_id = t.id)
|
|
115
|
+
OR (d.from_kind = 'goal' AND d.from_id = t.goal_id)
|
|
116
|
+
OR (d.from_kind = 'criterion' AND d.from_id IN (
|
|
117
|
+
SELECT tc.criterion_id FROM task_criteria tc WHERE tc.task_id = t.id)) )
|
|
118
|
+
)
|
|
119
|
+
AND task_is_fully_unblocked(t.id);
|
|
120
|
+
END IF;
|
|
121
|
+
RETURN NEW;
|
|
122
|
+
END;
|
|
123
|
+
$$ LANGUAGE plpgsql;
|
|
124
|
+
|
|
125
|
+
-- The triggers themselves are unchanged; 163 created them and CREATE OR REPLACE
|
|
126
|
+
-- above swaps the bodies underneath them in place.
|
|
@@ -1129,8 +1129,36 @@ async function updateTaskStatus(id, status) {
|
|
|
1129
1129
|
//
|
|
1130
1130
|
// The UPDATE repeats the WHERE rather than trusting the ids it just read, so a
|
|
1131
1131
|
// row that stops qualifying between the two statements is not promoted anyway.
|
|
1132
|
+
// 'backlog' MEANS TWO DIFFERENT THINGS, and this sweep may only drain one of
|
|
1133
|
+
// them (task 1004077). A task waiting on a TRIGGER — a dependency that has not
|
|
1134
|
+
// shipped — is mechanically unblockable, and draining those is what this sweep
|
|
1135
|
+
// was built for. A task waiting on a PERSON is a generated REQUEST nobody has
|
|
1136
|
+
// triaged, and "no unmet dependency" says nothing about whether a human has
|
|
1137
|
+
// looked at it. The system already knows the two are different: /backlog-review
|
|
1138
|
+
// exists to "separate rows waiting on a PERSON from rows waiting on a TRIGGER",
|
|
1139
|
+
// and it separates them by hand because nothing in the data did.
|
|
1140
|
+
//
|
|
1141
|
+
// Every cascade row is the second kind. cascade.js files at 'backlog' and never
|
|
1142
|
+
// 'ready' — one of the four properties tests/cascade.mjs pins — and the preamble
|
|
1143
|
+
// it writes onto every generated task says so to the reader: "it is a REQUEST,
|
|
1144
|
+
// filed at backlog, and it goes through the board's ordinary triage".
|
|
1145
|
+
//
|
|
1146
|
+
// WHAT IT COST. A cascade task carries dependencies: [], so it was trivially
|
|
1147
|
+
// "fully unblocked" and qualified the instant it existed. Measured on task
|
|
1148
|
+
// 1004037: filed 19:53:57, promoted 19:54:54 — 57 seconds, by this sweep, with
|
|
1149
|
+
// no person involved. studio.js:476 reads the artist's queue as
|
|
1150
|
+
// `?discipline=artist&status=backlog`, so every artist review was swept out of
|
|
1151
|
+
// the artist's room about a minute after arriving and reappeared in
|
|
1152
|
+
// /builder-start as ordinary claimable work. All four reviews open on
|
|
1153
|
+
// 2026-09-19 had been drained. One was then claimed into a worktree and graded
|
|
1154
|
+
// by a panel whose four lenses do not read a sentence (ADR 0298) — an artist
|
|
1155
|
+
// review performed without an artist, while the studio sat empty.
|
|
1156
|
+
//
|
|
1157
|
+
// The exclusion is by SOURCE, not by discipline: engineer-scoping-on-full-idea
|
|
1158
|
+
// is equally a request, and the preamble above is written on both.
|
|
1132
1159
|
const PROMOTABLE_BACKLOG_WHERE = `
|
|
1133
1160
|
WHERE t.status = 'backlog'
|
|
1161
|
+
AND t.source IS DISTINCT FROM 'cascade'
|
|
1134
1162
|
AND task_is_fully_unblocked(t.id)
|
|
1135
1163
|
AND NOT EXISTS (
|
|
1136
1164
|
SELECT 1
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.896",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.896",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.896",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.896'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -61,6 +61,11 @@ const EXCLUDED = [
|
|
|
61
61
|
{ id: 2000001, title: 'a dependency has not shipped', goal_id: 1000106, why: 'unmetDep' },
|
|
62
62
|
{ id: 2000002, title: 'carries an open blocker', goal_id: 1000106, why: 'openBlocker' },
|
|
63
63
|
{ id: 2000003, title: 'already out of backlog', goal_id: 1000106, why: 'notBacklog' },
|
|
64
|
+
// task 1004077. A cascade-filed REQUEST waits on a PERSON, not on a trigger,
|
|
65
|
+
// so this sweep must never drain it. It is the row most likely to qualify by
|
|
66
|
+
// accident: cascade.js files with no dependencies at all, so every other
|
|
67
|
+
// guard here passes it.
|
|
68
|
+
{ id: 2000004, title: 'a cascade request nobody has triaged', goal_id: 1000106, why: 'cascade' },
|
|
64
69
|
];
|
|
65
70
|
const FIXTURE = [...QUALIFIES, ...EXCLUDED];
|
|
66
71
|
|
|
@@ -69,10 +74,15 @@ function admittedBy(sql, rows) {
|
|
|
69
74
|
const backlogGuard = /status\s*=\s*'backlog'/i.test(sql);
|
|
70
75
|
const depGuard = /task_is_fully_unblocked\s*\(/i.test(sql);
|
|
71
76
|
const blockerGuard = /task_blockers/i.test(sql) && /b\.status\s*=\s*'open'/i.test(sql);
|
|
77
|
+
// IS DISTINCT FROM, not <>: source is nullable, and `NULL <> 'cascade'` is
|
|
78
|
+
// NULL, which would stop promoting every sourceless task instead. A <>
|
|
79
|
+
// spelling must NOT satisfy this guard.
|
|
80
|
+
const cascadeGuard = /source\s+IS\s+DISTINCT\s+FROM\s+'cascade'/i.test(sql);
|
|
72
81
|
return rows.filter((r) => {
|
|
73
82
|
if (r.why === 'notBacklog' && backlogGuard) return false;
|
|
74
83
|
if (r.why === 'unmetDep' && depGuard) return false;
|
|
75
84
|
if (r.why === 'openBlocker' && blockerGuard) return false;
|
|
85
|
+
if (r.why === 'cascade' && cascadeGuard) return false;
|
|
76
86
|
return true;
|
|
77
87
|
});
|
|
78
88
|
}
|
|
@@ -135,6 +145,36 @@ await test('a task carrying an open blocker is NOT promoted', async () => {
|
|
|
135
145
|
assert.ok(!idsOf(out.promoted).includes(2000002), 'and never promoted');
|
|
136
146
|
});
|
|
137
147
|
|
|
148
|
+
await test('a cascade REQUEST is NOT promoted — it waits on a person, not a trigger', async () => {
|
|
149
|
+
// task 1004077. This is the regression the whole task exists for. cascade.js
|
|
150
|
+
// files every generated task at 'backlog' and never 'ready', and tells its
|
|
151
|
+
// reader it "goes through the board's ordinary triage" — but a cascade row
|
|
152
|
+
// carries no dependencies, so before this guard it was trivially "fully
|
|
153
|
+
// unblocked" and this sweep promoted it within a minute of filing. That
|
|
154
|
+
// emptied the studio, whose queue is `?discipline=artist&status=backlog`:
|
|
155
|
+
// measured on task 1004037, filed 19:53:57 and promoted 19:54:54.
|
|
156
|
+
const pool = mkPool();
|
|
157
|
+
const out = await dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
|
|
158
|
+
assert.ok(!idsOf(out.candidates).includes(2000004),
|
|
159
|
+
'a cascade request became a promotion candidate — the source guard is gone from the SELECT, '
|
|
160
|
+
+ 'and every artist review will be swept out of the studio about a minute after it is filed');
|
|
161
|
+
assert.ok(!idsOf(out.promoted).includes(2000004), 'and never promoted');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await test('the cascade guard is in the SQL as IS DISTINCT FROM, not <>', async () => {
|
|
165
|
+
// Narrow on purpose. `t.source <> 'cascade'` reads as equivalent and is not:
|
|
166
|
+
// source is nullable and `NULL <> 'cascade'` is NULL, so the <> spelling
|
|
167
|
+
// stops promoting every task with no source at all — a much larger, much
|
|
168
|
+
// quieter bug than the one being fixed.
|
|
169
|
+
const pool = mkPool();
|
|
170
|
+
await dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
|
|
171
|
+
const select = pool.calls.find((c) => /^\s*SELECT/i.test(c.sql)).sql;
|
|
172
|
+
assert.match(select, /source\s+IS\s+DISTINCT\s+FROM\s+'cascade'/i,
|
|
173
|
+
'the SELECT must exclude cascade rows with IS DISTINCT FROM');
|
|
174
|
+
assert.doesNotMatch(select, /source\s*<>\s*'cascade'/i,
|
|
175
|
+
'the <> spelling silently withholds every sourceless task instead');
|
|
176
|
+
});
|
|
177
|
+
|
|
138
178
|
await test('a task already out of backlog is NOT touched', async () => {
|
|
139
179
|
const pool = mkPool();
|
|
140
180
|
const out = await dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// tests/promotion_triggers_skip_cascade.mjs
|
|
2
|
+
//
|
|
3
|
+
// The SECOND promoter (task 1004077). Static shape guard for core_250, in the
|
|
4
|
+
// style of tests/task_unblocked_open_blocker.mjs's guard for core_245 — and for
|
|
5
|
+
// the same reason: CI runs a DB-free unit gate, so a migration that has not
|
|
6
|
+
// been applied anywhere is otherwise asserted by nothing at all.
|
|
7
|
+
//
|
|
8
|
+
// WHY THE TRIGGERS NEED THEIR OWN GUARD. tests/promotion_sweep.mjs covers the
|
|
9
|
+
// JS sweep. The sweep is not the only thing that promotes. Each of the three
|
|
10
|
+
// migration-163 trigger functions matches a dependency edge that may be
|
|
11
|
+
// anchored at the task OR AT ITS GOAL (`d.from_kind = 'goal' AND d.from_id =
|
|
12
|
+
// t.goal_id`), and a cascade review INHERITS the reviewed task's goal
|
|
13
|
+
// (cascade.js property 3). So a goal-level edge promotes a cascade row that has
|
|
14
|
+
// no dependencies of its own. Fixing only the sweep would have left that path
|
|
15
|
+
// open and made the defect come back intermittently — which is worse than not
|
|
16
|
+
// fixing it, because an intermittent drain is much harder to see than a
|
|
17
|
+
// constant one.
|
|
18
|
+
//
|
|
19
|
+
// What this proves and where the proof stops: that the SQL in the migration
|
|
20
|
+
// file carries the guard, and that no later migration has silently redefined
|
|
21
|
+
// these functions without it. It does not prove the migration applied. That
|
|
22
|
+
// needs Postgres and is called out in the ship notes rather than papered over.
|
|
23
|
+
//
|
|
24
|
+
// Run: node tests/promotion_triggers_skip_cascade.mjs
|
|
25
|
+
|
|
26
|
+
import { strict as assert } from 'node:assert';
|
|
27
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { makeRunner } from './helpers.mjs';
|
|
31
|
+
|
|
32
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
const repoRoot = join(__dirname, '..');
|
|
34
|
+
const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
|
|
35
|
+
|
|
36
|
+
const MIG = 'core_250_cascade_requests_do_not_auto_promote.sql';
|
|
37
|
+
|
|
38
|
+
// The three promotion triggers, by the state transition each reacts to.
|
|
39
|
+
const TRIGGERS = [
|
|
40
|
+
'promote_dependents_on_ship',
|
|
41
|
+
'promote_dependents_on_criterion_satisfy',
|
|
42
|
+
'promote_dependents_on_goal_achieve',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
// Comments carry the rationale; strip them before asserting on the executable
|
|
46
|
+
// body so prose can never stand in for the real SQL.
|
|
47
|
+
const body = read('migrations', MIG).replace(/^\s*--.*$/gm, '').replace(/\s+/g, ' ');
|
|
48
|
+
|
|
49
|
+
// The body of one function, so a guard present in ONE cannot satisfy an
|
|
50
|
+
// assertion about another — the defect this whole task is about was a guard
|
|
51
|
+
// that existed in one place and not the other.
|
|
52
|
+
function fnBody(name) {
|
|
53
|
+
const start = body.indexOf(`CREATE OR REPLACE FUNCTION ${name}()`);
|
|
54
|
+
assert.notEqual(start, -1, `${MIG} does not redefine ${name}`);
|
|
55
|
+
const after = body.slice(start + 1);
|
|
56
|
+
const next = after.indexOf('CREATE OR REPLACE FUNCTION');
|
|
57
|
+
return next === -1 ? after : after.slice(0, next);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { test, summary } = makeRunner();
|
|
61
|
+
|
|
62
|
+
await test('core_250 redefines all three promotion triggers', () => {
|
|
63
|
+
for (const name of TRIGGERS) {
|
|
64
|
+
assert.match(body, new RegExp(`CREATE OR REPLACE FUNCTION ${name}\\(\\)`), `${name} is redefined`);
|
|
65
|
+
}
|
|
66
|
+
assert.doesNotMatch(body, /DROP\s+FUNCTION/i, 'CREATE OR REPLACE only — a DROP would take the triggers with it');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await test('each trigger refuses to promote a cascade row', () => {
|
|
70
|
+
for (const name of TRIGGERS) {
|
|
71
|
+
assert.match(fnBody(name), /t\.source IS DISTINCT FROM 'cascade'/,
|
|
72
|
+
`${name} promotes cascade requests — an artist review reached by a goal-level edge is still `
|
|
73
|
+
+ 'swept out of the studio, and the sweep-only fix does not cover it');
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await test('the guard is IS DISTINCT FROM, never <>', () => {
|
|
78
|
+
// Narrow on purpose, and not a style note. tasks.source is nullable, so
|
|
79
|
+
// `NULL <> 'cascade'` is NULL and the <> spelling stops promoting every task
|
|
80
|
+
// with no source at all — a much larger and much quieter bug than the one
|
|
81
|
+
// being fixed here.
|
|
82
|
+
for (const name of TRIGGERS) {
|
|
83
|
+
assert.doesNotMatch(fnBody(name), /t\.source\s*<>\s*'cascade'/,
|
|
84
|
+
`${name} uses <>, which withholds every sourceless task instead of every cascade one`);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await test('nothing else was weakened while adding the guard', () => {
|
|
89
|
+
// core_250 restates whole function bodies, so it inherits only what it
|
|
90
|
+
// restates — the exact mechanism by which the open-blocker clause was lost
|
|
91
|
+
// twice (see tests/task_unblocked_open_blocker.mjs). Assert the guards that
|
|
92
|
+
// were already there survived the retype.
|
|
93
|
+
for (const name of TRIGGERS) {
|
|
94
|
+
const fn = fnBody(name);
|
|
95
|
+
assert.match(fn, /t\.status = 'backlog'/, `${name} still promotes only from backlog`);
|
|
96
|
+
assert.match(fn, /t\.kind <> 'spike'/, `${name} still skips spikes`);
|
|
97
|
+
assert.match(fn, /task_is_fully_unblocked\(t\.id\)/, `${name} still re-checks the shared predicate`);
|
|
98
|
+
assert.match(fn, /status = 'ready'/, `${name} still promotes to ready`);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await test('core_250 is the LAST definition of each trigger in migrations/', () => {
|
|
103
|
+
// The drift tripwire, and the assertion that would have caught migration 177
|
|
104
|
+
// on the day it landed. A later whole-body redefinition inherits only what it
|
|
105
|
+
// restates, so this fails the moment someone redefines one of these functions
|
|
106
|
+
// without carrying the cascade guard forward.
|
|
107
|
+
const files = readdirSync(join(repoRoot, 'migrations')).filter((f) => f.endsWith('.sql')).sort();
|
|
108
|
+
for (const name of TRIGGERS) {
|
|
109
|
+
const defines = new RegExp(`CREATE OR REPLACE FUNCTION ${name}\\(\\)`);
|
|
110
|
+
const defs = files.filter((f) => defines.test(read('migrations', f)));
|
|
111
|
+
assert.ok(defs.includes(MIG), `${MIG} defines ${name}`);
|
|
112
|
+
assert.equal(defs[defs.length - 1], MIG,
|
|
113
|
+
`a later migration redefines ${name} and must restate the cascade guard: ${defs.join(', ')}`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
summary('promotion-trigger cascade guard');
|
|
@@ -113,10 +113,22 @@ await test('every OTHER gate still spells "open" the same way', () => {
|
|
|
113
113
|
// predicate is fixed that guard is belt-and-braces — and it stays, because a
|
|
114
114
|
// sweep that mass-promotes must not inherit a single point of failure from a
|
|
115
115
|
// predicate that has already been reverted twice.
|
|
116
|
+
// Asserted as the TWO things this test names, not as one verbatim copy of the
|
|
117
|
+
// whole clause. The literal pin that used to stand here broke on task
|
|
118
|
+
// 1004077, which ADDED a guard (`t.source IS DISTINCT FROM 'cascade'`) — a
|
|
119
|
+
// strictly stronger predicate that this test then reported as a regression.
|
|
120
|
+
// A pin that reds when the clause gets SAFER is testing the string, not the
|
|
121
|
+
// rule. Both halves are still asserted independently, so neither can be
|
|
122
|
+
// dropped quietly.
|
|
123
|
+
const sweepClause = (code('modules', 'lifecycle', 'db-tasks.js')
|
|
124
|
+
.match(/PROMOTABLE_BACKLOG_WHERE = `([^`]*)`/) || [])[1];
|
|
125
|
+
assert.ok(sweepClause, 'PROMOTABLE_BACKLOG_WHERE is no longer a single template literal');
|
|
126
|
+
assert.match(sweepClause, /task_is_fully_unblocked\(t\.id\)/,
|
|
127
|
+
'the sweep still calls the shared predicate');
|
|
116
128
|
assert.match(
|
|
117
|
-
|
|
118
|
-
/
|
|
119
|
-
'
|
|
129
|
+
sweepClause,
|
|
130
|
+
/NOT EXISTS \( SELECT 1 FROM task_blockers tb JOIN blockers b ON b\.id = tb\.blocker_id WHERE tb\.task_id = t\.id AND b\.status = 'open'\)/,
|
|
131
|
+
'and keeps its OWN open-blocker guard rather than inheriting the predicate as a single point of failure'
|
|
120
132
|
);
|
|
121
133
|
});
|
|
122
134
|
|