@nanobpm/nano-workforce 0.95.0 → 0.96.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 +7 -0
- package/app/agentic/vocab/crew-vocab.test.ts +12 -0
- package/app/agentic/vocab/crew-vocab.ts +18 -0
- package/app/agentic/vocab/demand-report.test.ts +34 -0
- package/app/agentic/vocab/demand-report.ts +21 -1
- package/app/agentic/vocab/job-types.test.ts +107 -0
- package/app/agentic/vocab/job-types.ts +70 -0
- package/app/feature.ts +43 -0
- package/app/interEpicRegression.test.ts +516 -0
- package/app/plan.ts +15 -0
- package/app/pollUserTasks.test.ts +31 -0
- package/app/service.ts +54 -3
- package/app/userTasks.test.ts +14 -0
- package/app/userTasks.ts +15 -1
- package/app/waitGate.test.ts +176 -0
- package/app/waitGate.ts +199 -0
- package/app/waitGatePoll.test.ts +143 -0
- package/app/waitGateVisibility.test.ts +55 -0
- package/db/migrations/047_plan_wait_gate.sql +34 -0
- package/db/migrations/048_feature_escalations.sql +51 -0
- package/e2e/inter-epic-dependency.e2e.ts +227 -0
- package/package.json +1 -1
- package/pages/epic-detail.page.json +28 -0
- package/pages/epic.page.json +1 -0
- package/pages/tasks.page.json +133 -3
- package/resources/processes/plan-fanout.bpmn +1 -0
- package/workers/record-feature-escalation/worker.test.ts +27 -3
- package/workers/record-feature-escalation/worker.ts +7 -1
- package/workers/select-wave/worker.test.ts +37 -0
- package/workers/select-wave/worker.ts +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.96.0](https://github.com/nanobpm/nano-workforce/compare/v0.95.0...v0.96.0) (2026-08-19)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **escalations:** consolidate on native user_tasks — expand half (issue [#305](https://github.com/nanobpm/nano-workforce/issues/305)) ([#310](https://github.com/nanobpm/nano-workforce/issues/310)) ([fc5f4d4](https://github.com/nanobpm/nano-workforce/commit/fc5f4d4680223d0c7ca4572a7f883ce4257a26a5)), closes [nano-ide#333](https://github.com/nano-ide/issues/333)
|
|
7
|
+
|
|
1
8
|
# [0.95.0](https://github.com/nanobpm/nano-workforce/compare/v0.94.0...v0.95.0) (2026-08-19)
|
|
2
9
|
|
|
3
10
|
|
|
@@ -29,11 +29,23 @@ test("it ships the crew's leaf tokens", () => {
|
|
|
29
29
|
"implementation.reviewer",
|
|
30
30
|
"ci.runner",
|
|
31
31
|
"decide",
|
|
32
|
+
"senior",
|
|
32
33
|
]) {
|
|
33
34
|
assert(tokens.includes(expected), `expected token ${expected}`);
|
|
34
35
|
}
|
|
35
36
|
});
|
|
36
37
|
|
|
38
|
+
test("a representative senior worker resolves the rank-gated `senior` role (any cognition, weight>=4)", () => {
|
|
39
|
+
const implementation = crewResolver().resolve({ cognition: "implementation", weight: 5, family: "frontier" });
|
|
40
|
+
const planning = crewResolver().resolve({ cognition: "planning", weight: 4, family: "kimi" });
|
|
41
|
+
assert(implementation.tokens.includes("senior"), "an implementation senior serves `senior`");
|
|
42
|
+
assert(planning.tokens.includes("senior"), "a planning senior serves `senior` (rank, not cognition)");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a junior-weight worker does not resolve the `senior` rank role", () => {
|
|
46
|
+
assert(!crewResolver().resolve({ cognition: "implementation", weight: 2, family: "kimi" }).tokens.includes("senior"));
|
|
47
|
+
});
|
|
48
|
+
|
|
37
49
|
test("the spar role carries two distinct-family named seats", () => {
|
|
38
50
|
const spar = crewResolver().roleForToken("planning.spar");
|
|
39
51
|
assert(spar !== undefined);
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
// - implementation.senior / .junior / .reviewer — the build crew (reviewer is red/blue).
|
|
18
18
|
// - ci.runner — the CI runner.
|
|
19
19
|
// - decide — the bare decision role.
|
|
20
|
+
// - senior — the bare seniority-rank role the deployed fleet agent job types (`senior:*`)
|
|
21
|
+
// derive onto (issue #323); rank-gated (weight≥4), cognition-agnostic.
|
|
20
22
|
//
|
|
21
23
|
// Capability (cognition / weight / family / host) is an ENROLMENT attribute, never a routing token
|
|
22
24
|
// (ADR 0056 invariant 3): each role's `requires` gate — not the token — decides WHO may fill it.
|
|
@@ -132,6 +134,22 @@ export const CREW_VOCAB: VocabDocument = deepFreeze({
|
|
|
132
134
|
},
|
|
133
135
|
},
|
|
134
136
|
},
|
|
137
|
+
// The deployed fleet agent tasks are colon-form job types `senior:<task>` (`senior:feature`,
|
|
138
|
+
// `senior:retro`, `senior:rebase`, …). The `senior` prefix is a SENIORITY rank, not a cognition —
|
|
139
|
+
// one senior worker serves every `senior:*` task — so it maps to a single bare `senior` routing
|
|
140
|
+
// role gated purely on rank (weight≥4), cognition-agnostic. Any suitably-senior enrolled worker
|
|
141
|
+
// resolves it, so `app/agentic/vocab/job-types.ts`'s `senior:<task>` → `senior` derivation always
|
|
142
|
+
// lands on live supply (issue #323). Deliberately NOT one role per task: a per-task list here
|
|
143
|
+
// would duplicate the deployed models (issue #323 acceptance §3 — derivation over duplication).
|
|
144
|
+
senior: {
|
|
145
|
+
roles: {
|
|
146
|
+
senior: {
|
|
147
|
+
requires: ["weight>=4"],
|
|
148
|
+
weight: 5,
|
|
149
|
+
seats: 1,
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
135
153
|
},
|
|
136
154
|
});
|
|
137
155
|
|
|
@@ -13,6 +13,40 @@ const leaf = (taskType: string): TaskDefinitionLeaf => ({ taskType, process: "p"
|
|
|
13
13
|
|
|
14
14
|
const plannerFrontier: RegisteredWorker = { instance: "w-front", capability: { cognition: "planning", weight: 5, family: "frontier" } };
|
|
15
15
|
const plannerKimi: RegisteredWorker = { instance: "w-kimi", capability: { cognition: "planning", weight: 5, family: "kimi" } };
|
|
16
|
+
const seniorImpl: RegisteredWorker = { instance: "w-senior", capability: { cognition: "implementation", weight: 5, family: "frontier" } };
|
|
17
|
+
|
|
18
|
+
test("a deployed colon-form agent job type resolves to live supply from an enrolled senior worker (#323)", () => {
|
|
19
|
+
const report = buildRegistryReport({
|
|
20
|
+
taskDefinitions: [leaf("senior:feature"), leaf("senior:retro"), leaf("senior:rebase")],
|
|
21
|
+
workers: [seniorImpl],
|
|
22
|
+
now: NOW,
|
|
23
|
+
});
|
|
24
|
+
// All three colon-form agent job types bridge onto the bare `senior` routing role, which the senior
|
|
25
|
+
// worker supplies — so the board shows live supply, not a false RED, and nothing is nonAgentic.
|
|
26
|
+
assertEquals(report.missing, []);
|
|
27
|
+
assertEquals(report.nonAgentic, []);
|
|
28
|
+
const senior = report.networks.find((n) => n.network === "senior");
|
|
29
|
+
assert(senior !== undefined, "the `senior` network bucket is present");
|
|
30
|
+
const token = senior.tokens.find((t) => t.token === "senior");
|
|
31
|
+
assert(token !== undefined);
|
|
32
|
+
assertEquals(token.satisfied, true);
|
|
33
|
+
assertEquals(token.supply, 1);
|
|
34
|
+
assertEquals(token.instances, ["w-senior"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("an agent job type with no enrolled senior worker is flagged missing, not nonAgentic (#323)", () => {
|
|
38
|
+
const report = buildRegistryReport({ taskDefinitions: [leaf("senior:feature")], workers: [], now: NOW });
|
|
39
|
+
assert(report.missing.includes("senior"), "senior demand with no supplier is missing (red)");
|
|
40
|
+
assertEquals(report.nonAgentic, []);
|
|
41
|
+
assertEquals(report.status, "red");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("ordinary host jobs (pr.*) are not colon-form and pass through the bridge untouched", () => {
|
|
45
|
+
const report = buildRegistryReport({ taskDefinitions: [leaf("pr.finalize")], workers: [], now: NOW });
|
|
46
|
+
const pr = report.networks.find((n) => n.network === "pr");
|
|
47
|
+
assert(pr !== undefined, "pr.finalize stays a pr-network routing token");
|
|
48
|
+
assert(pr.tokens.some((t) => t.token === "pr.finalize"));
|
|
49
|
+
});
|
|
16
50
|
|
|
17
51
|
test("flags a demanded leaf with no supplier as missing (red) and a supplied leaf as satisfied", () => {
|
|
18
52
|
const report = buildRegistryReport({
|
|
@@ -23,6 +23,7 @@ import type { RegistryReport as WireRegistryReport } from "../../../nano-generat
|
|
|
23
23
|
import { envVar } from "../../version.ts";
|
|
24
24
|
import { currentPresenceRegistry } from "../families/presence.family.ts";
|
|
25
25
|
import { CREW_VOCAB_VERSION, crewResolver } from "./crew-vocab.ts";
|
|
26
|
+
import { jobTypeToRoutingToken } from "./job-types.ts";
|
|
26
27
|
|
|
27
28
|
/** The full registry report: the package's demand×supply model plus this app's version/provenance. */
|
|
28
29
|
export interface RegistryReport extends DemandSupplyReport {
|
|
@@ -82,6 +83,25 @@ export interface BuildRegistryInput {
|
|
|
82
83
|
readonly now?: Date;
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Bridge the deployed fleet agent job types onto crew routing tokens so the demand×supply match can
|
|
88
|
+
* see live supply for them. The deployed AGENT tasks are colon-form job types (`senior:feature`,
|
|
89
|
+
* `senior:retro`, …) the crew vocab's dot-form SERVE tokens never string-match; each such leaf is
|
|
90
|
+
* rewritten to the routing token an enrolled worker resolves (`senior:<task>` → `senior`, the bare
|
|
91
|
+
* rank role) via {@link jobTypeToRoutingToken}. Ordinary host jobs (`pr.*`, already dot-form tokens)
|
|
92
|
+
* are not in colon form, so they pass through untouched. Element provenance is preserved. (Issue #323
|
|
93
|
+
* design choice 1b: an app-tier job-type → routing-token derivation, no parallel task-type list.)
|
|
94
|
+
*/
|
|
95
|
+
export function bridgeDemandLeaves(
|
|
96
|
+
taskDefinitions: readonly TaskDefinitionLeaf[] | undefined,
|
|
97
|
+
): readonly TaskDefinitionLeaf[] | undefined {
|
|
98
|
+
if (taskDefinitions === undefined) return undefined;
|
|
99
|
+
return taskDefinitions.map((leaf) => {
|
|
100
|
+
const token = jobTypeToRoutingToken(leaf.taskType);
|
|
101
|
+
return token === undefined ? leaf : { ...leaf, taskType: token };
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
85
105
|
/**
|
|
86
106
|
* Compute the registry report from demand + supply. Pure and deterministic (every list the package
|
|
87
107
|
* emits is sorted), so it is safe to render straight into the board and diff frame-to-frame.
|
|
@@ -89,7 +109,7 @@ export interface BuildRegistryInput {
|
|
|
89
109
|
export function buildRegistryReport(input: BuildRegistryInput): RegistryReport {
|
|
90
110
|
const demandUnavailable = input.taskDefinitions === undefined;
|
|
91
111
|
const report = computeDemandSupply({
|
|
92
|
-
taskDefinitions: input.taskDefinitions ?? [],
|
|
112
|
+
taskDefinitions: bridgeDemandLeaves(input.taskDefinitions) ?? [],
|
|
93
113
|
workers: input.workers,
|
|
94
114
|
resolver: crewResolver(),
|
|
95
115
|
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Tests for the deployed-job-type ↔ crew-routing-token bridge (issue #323), including the
|
|
2
|
+
// defect-class regression guard: every deployed prompt-bearing agent job type must resolve to a
|
|
3
|
+
// SERVE token a representative enrolled senior worker can supply, so a newly-added agent task that is
|
|
4
|
+
// not wired into the crew vocab fails CI instead of silently showing RED on the demand×supply board.
|
|
5
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { assert, assertEquals } from "#test-assert";
|
|
10
|
+
import type { Capability } from "@nanobpm/agentic/protocol";
|
|
11
|
+
import { crewResolver } from "./crew-vocab.ts";
|
|
12
|
+
import { jobTypeToRoutingToken, promptBearingTaskTypes } from "./job-types.ts";
|
|
13
|
+
|
|
14
|
+
const PROCESSES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../../resources/processes");
|
|
15
|
+
|
|
16
|
+
/** Every prompt-bearing agent job type deployed across the BPMN models, distinct and sorted. */
|
|
17
|
+
function deployedAgentJobTypes(): string[] {
|
|
18
|
+
const types = new Set<string>();
|
|
19
|
+
for (const file of readdirSync(PROCESSES_DIR)) {
|
|
20
|
+
if (!file.endsWith(".bpmn")) continue;
|
|
21
|
+
for (const type of promptBearingTaskTypes(readFileSync(join(PROCESSES_DIR, file), "utf8"))) {
|
|
22
|
+
types.add(type);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return [...types].sort();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A representative enrolled senior worker — the staffing the board assumes when it reports supply.
|
|
29
|
+
const seniorWorker: Capability = { cognition: "implementation", weight: 5, family: "frontier", host: "h1" };
|
|
30
|
+
|
|
31
|
+
test("jobTypeToRoutingToken derives the bare rank token from a colon-form agent job type", () => {
|
|
32
|
+
assertEquals(jobTypeToRoutingToken("senior:feature"), "senior");
|
|
33
|
+
assertEquals(jobTypeToRoutingToken("senior:retro"), "senior");
|
|
34
|
+
assertEquals(jobTypeToRoutingToken("senior:plan-review"), "senior");
|
|
35
|
+
assertEquals(jobTypeToRoutingToken("senior:trial-merge"), "senior");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("jobTypeToRoutingToken returns undefined for a non-agent (dot-form host) job type", () => {
|
|
39
|
+
assertEquals(jobTypeToRoutingToken("pr.finalize"), undefined);
|
|
40
|
+
assertEquals(jobTypeToRoutingToken("pr.record-plan"), undefined);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("jobTypeToRoutingToken returns undefined for malformed / non-routing rank forms", () => {
|
|
44
|
+
assertEquals(jobTypeToRoutingToken(":feature"), undefined); // empty rank
|
|
45
|
+
assertEquals(jobTypeToRoutingToken("senior:"), undefined); // empty task
|
|
46
|
+
assertEquals(jobTypeToRoutingToken("senior"), undefined); // no colon at all
|
|
47
|
+
assertEquals(jobTypeToRoutingToken("Senior:feature"), undefined); // not a valid segment
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("jobTypeToRoutingToken rejects a dotted (multi-segment) rank prefix", () => {
|
|
51
|
+
// A dotted prefix is a multi-segment routing token, not a bare rank role — bridging it would
|
|
52
|
+
// distort demand/supply matching, so it must not derive.
|
|
53
|
+
assertEquals(jobTypeToRoutingToken("implementation.senior:feature"), undefined);
|
|
54
|
+
assertEquals(jobTypeToRoutingToken("planning.spar:plan"), undefined);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("jobTypeToRoutingToken rejects a multi-colon job type (not `<rank>:<task>` form)", () => {
|
|
58
|
+
assertEquals(jobTypeToRoutingToken("senior:feature:extra"), undefined);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("promptBearingTaskTypes picks a prompt-linked agent task and skips a plain host task", () => {
|
|
62
|
+
const xml = `
|
|
63
|
+
<bpmn:serviceTask id="host">
|
|
64
|
+
<bpmn:extensionElements>
|
|
65
|
+
<zeebe:taskDefinition type="pr.finalize" />
|
|
66
|
+
</bpmn:extensionElements>
|
|
67
|
+
</bpmn:serviceTask>
|
|
68
|
+
<bpmn:serviceTask id="agent">
|
|
69
|
+
<bpmn:extensionElements>
|
|
70
|
+
<zeebe:taskDefinition type="senior:feature" />
|
|
71
|
+
<zeebe:linkedResources>
|
|
72
|
+
<zeebe:linkedResource resourceId="feature.md" resourceType="GenericScript" linkName="prompt" />
|
|
73
|
+
</zeebe:linkedResources>
|
|
74
|
+
</bpmn:extensionElements>
|
|
75
|
+
</bpmn:serviceTask>`;
|
|
76
|
+
assertEquals(promptBearingTaskTypes(xml), ["senior:feature"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a representative senior worker serves the bare `senior` routing role", () => {
|
|
80
|
+
assert(crewResolver().resolve(seniorWorker).tokens.includes("senior"), "senior worker serves `senior`");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("retro and rebase specifically resolve to a supplying role", () => {
|
|
84
|
+
for (const jobType of ["senior:retro", "senior:rebase"]) {
|
|
85
|
+
const token = jobTypeToRoutingToken(jobType);
|
|
86
|
+
assert(token !== undefined, `${jobType} derives a routing token`);
|
|
87
|
+
assert(
|
|
88
|
+
crewResolver().resolve(seniorWorker).tokens.includes(token),
|
|
89
|
+
`${jobType} → ${token} is supplied by a senior worker`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("DEFECT-CLASS GUARD: every deployed prompt-bearing agent job type resolves to a suppliable SERVE token", () => {
|
|
95
|
+
const jobTypes = deployedAgentJobTypes();
|
|
96
|
+
// Sanity: the models really do declare agent tasks (guard is not vacuously green).
|
|
97
|
+
assert(jobTypes.length > 0, "the deployed models declare prompt-bearing agent tasks");
|
|
98
|
+
const serve = new Set(crewResolver().resolve(seniorWorker).tokens);
|
|
99
|
+
for (const jobType of jobTypes) {
|
|
100
|
+
const token = jobTypeToRoutingToken(jobType);
|
|
101
|
+
assert(token !== undefined, `deployed agent job type ${jobType} is not in <rank>:<task> form`);
|
|
102
|
+
assert(
|
|
103
|
+
serve.has(token),
|
|
104
|
+
`deployed agent job type ${jobType} resolves to ${token}, which no enrolled senior worker supplies — wire it into the crew vocab`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// nano-workforce — the deployed-job-type ↔ crew-routing-token bridge (issue #323).
|
|
2
|
+
//
|
|
3
|
+
// The demand×supply board resolves live SUPPLY through the crew vocab, which emits dot-form routing
|
|
4
|
+
// tokens (`implementation.senior`, `planning.spar`, …). But the deployed fleet agent tasks are
|
|
5
|
+
// COLON-form job types (`senior:feature`, `senior:retro`, …) — the `<zeebe:taskDefinition type>` the
|
|
6
|
+
// engine matches 1:1. The two never string-match, so an advertised agentic demand shows RED (no
|
|
7
|
+
// supply) even when a suitably-capable senior worker is enrolled.
|
|
8
|
+
//
|
|
9
|
+
// This module is the app-tier bridge (design choice 1b): a pure DERIVATION from a deployed agent job
|
|
10
|
+
// type to the crew routing token an enrolled worker resolves — NOT a hand-maintained parallel list of
|
|
11
|
+
// task types (AGENTS.md: derivation over duplication; issue #323 acceptance §3). The colon in a fleet
|
|
12
|
+
// job type is `<rank>:<task>`: the `rank` prefix is a seniority assertion the crew vocab models as a
|
|
13
|
+
// bare rank role, and the `task` suffix is a job selector that does not change WHICH worker serves it
|
|
14
|
+
// (one senior worker serves every `senior:*` task). So the derivation is: take the rank segment as the
|
|
15
|
+
// bare routing token. `senior:retro` and `senior:feature` both derive to the `senior` role a
|
|
16
|
+
// weight≥4 worker fills — no per-task role, no drift surface.
|
|
17
|
+
//
|
|
18
|
+
// The prompt-bearing scan identifies the deployed AGENT tasks (a `<zeebe:linkedResource … linkName=
|
|
19
|
+
// "prompt">` on the service task) so a regression guard can enumerate the real demand corpus straight
|
|
20
|
+
// from the models and assert every agent job type resolves to a suppliable token.
|
|
21
|
+
|
|
22
|
+
import { isSegmentName } from "@nanobpm/agentic/protocol";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Derive the crew routing token an enrolled worker resolves to for a deployed fleet agent job type,
|
|
26
|
+
* or `undefined` when the type is not an agent job type in `<rank>:<task>` colon form (e.g. an
|
|
27
|
+
* ordinary host job like `pr.finalize`, which is already a dot-form token and is left untouched).
|
|
28
|
+
*
|
|
29
|
+
* The rank segment is returned as the bare routing token: `senior:retro` → `senior`. The derivation
|
|
30
|
+
* is purely syntactic — it never enumerates task types — so a newly-added `senior:<task>` is covered
|
|
31
|
+
* automatically by the same rank role, while a new RANK (`principal:*`) that has no crew role surfaces
|
|
32
|
+
* as unsupplied and trips the regression guard.
|
|
33
|
+
*/
|
|
34
|
+
export function jobTypeToRoutingToken(jobType: string): string | undefined {
|
|
35
|
+
const colon = jobType.indexOf(":");
|
|
36
|
+
if (colon <= 0) return undefined;
|
|
37
|
+
const rank = jobType.slice(0, colon);
|
|
38
|
+
const task = jobType.slice(colon + 1);
|
|
39
|
+
if (task.length === 0) return undefined;
|
|
40
|
+
// The grammar is exactly `<rank>:<task>` with a SINGLE colon: a further colon (e.g.
|
|
41
|
+
// `senior:feature:extra`) is not this form, so reject it rather than silently deriving `senior`.
|
|
42
|
+
if (task.indexOf(":") !== -1) return undefined;
|
|
43
|
+
// The rank must be a bare SINGLE-SEGMENT routing token (a role like `senior`) — not a dotted,
|
|
44
|
+
// multi-segment token like `implementation.senior`, which would distort demand/supply matching.
|
|
45
|
+
// `isSegmentName` enforces the single-segment `[a-z][a-z0-9-]*` grammar (no dots, no seat marker).
|
|
46
|
+
return isSegmentName(rank) ? rank : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SERVICE_TASK = /<(?:\w+:)?serviceTask\b[\s\S]*?<\/(?:\w+:)?serviceTask>/g;
|
|
50
|
+
const TASK_DEFINITION_TYPE = /<(?:\w+:)?taskDefinition\b[^>]*\btype="([^"]*)"/;
|
|
51
|
+
const PROMPT_LINK = /<(?:\w+:)?linkedResource\b[^>]*\blinkName="prompt"/;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Scan one BPMN document for the job types of its PROMPT-BEARING service tasks — the deployed fleet
|
|
55
|
+
* AGENT tasks. A task is prompt-bearing iff it carries a `<zeebe:linkedResource … linkName="prompt">`
|
|
56
|
+
* (the base-prompt resource the engine delivers to the agent). Ordinary host jobs (no prompt link)
|
|
57
|
+
* are excluded. Returns the distinct task types in first-occurrence order.
|
|
58
|
+
*/
|
|
59
|
+
export function promptBearingTaskTypes(xml: string): string[] {
|
|
60
|
+
const seen = new Set<string>();
|
|
61
|
+
const types: string[] = [];
|
|
62
|
+
for (const [block] of xml.matchAll(SERVICE_TASK)) {
|
|
63
|
+
if (!PROMPT_LINK.test(block)) continue;
|
|
64
|
+
const type = block.match(TASK_DEFINITION_TYPE)?.[1];
|
|
65
|
+
if (type === undefined || type.length === 0 || seen.has(type)) continue;
|
|
66
|
+
seen.add(type);
|
|
67
|
+
types.push(type);
|
|
68
|
+
}
|
|
69
|
+
return types;
|
|
70
|
+
}
|
package/app/feature.ts
CHANGED
|
@@ -180,6 +180,49 @@ export function deriveFeatureDelivery(prStatus: string | null): FeatureDeliveryR
|
|
|
180
180
|
* parks on when the agent escalates. `pollFeatureEscalations` reconciles it onto the read model. */
|
|
181
181
|
export const FEATURE_ESCALATION_ELEMENT = "feature-escalation";
|
|
182
182
|
|
|
183
|
+
/** One append-only audit row per `feature-escalation` ENTRY (issue #305). Mirrors the surviving
|
|
184
|
+
* `plan_reviews` / `escalations` / `plan_trial_merges` audit logs: it is the canonical, poller-readable
|
|
185
|
+
* source for a parked run's escalation `question`, so the denormalised `feature_runs.escalation_question`
|
|
186
|
+
* column can be dropped in the later contract phase. `id` is an AUTOINCREMENT PK, so the newest row per
|
|
187
|
+
* `feature_key` is the live question (`latestFeatureEscalationQuestion`). Never updated or deleted. */
|
|
188
|
+
export interface FeatureEscalationRow {
|
|
189
|
+
id: number;
|
|
190
|
+
feature_key: string;
|
|
191
|
+
question: string | null;
|
|
192
|
+
created_at: string;
|
|
193
|
+
/** The engine `jobKey` that wrote the row — an idempotency guard so a `record-feature-escalation`
|
|
194
|
+
* job retried after its insert (crash/timeout pre-completion) reuses its row instead of appending a
|
|
195
|
+
* duplicate (mirrors `plan_reviews.job_key`). NULL only for migration-048 backfill rows. */
|
|
196
|
+
job_key: string | null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Accessor for the append-only `feature_escalations` audit log (migration 048). Written by
|
|
200
|
+
* `record-feature-escalation` (one row per escalation entry), read by `pollUserTasks` to enrich the
|
|
201
|
+
* open `feature-escalation` task's question — the feature analogue of `plan_reviews` / `escalations`. */
|
|
202
|
+
export const featureEscalations = (data: DataLayer) =>
|
|
203
|
+
data.table<FeatureEscalationRow>("feature_escalations", "id");
|
|
204
|
+
|
|
205
|
+
/** Append one `feature_escalations` audit row capturing the agent's escalation `question` while it is
|
|
206
|
+
* still in scope on the `record-feature-escalation` job. Append-only, so this is the canonical record
|
|
207
|
+
* of what was asked — `pollUserTasks` reads the newest row per feature as the live question. The
|
|
208
|
+
* `jobKey` is an idempotency guard: `record-feature-escalation` is at-least-once, so a retry after the
|
|
209
|
+
* insert (crash/timeout pre-completion) re-runs with the SAME `jobKey` and must reuse the existing row
|
|
210
|
+
* rather than append a duplicate (mirrors `record-plan-review` guarding `plan_reviews` by `job_key`). */
|
|
211
|
+
export async function recordFeatureEscalation(
|
|
212
|
+
data: DataLayer,
|
|
213
|
+
entry: { featureKey: string; question: string | null; jobKey: string },
|
|
214
|
+
): Promise<void> {
|
|
215
|
+
const table = featureEscalations(data);
|
|
216
|
+
// A prior attempt of THIS job already recorded its row — reuse it, don't append a duplicate.
|
|
217
|
+
if (await table.findOne({ feature_key: entry.featureKey, job_key: entry.jobKey })) return;
|
|
218
|
+
await table.insert({
|
|
219
|
+
feature_key: entry.featureKey,
|
|
220
|
+
question: entry.question,
|
|
221
|
+
created_at: new Date().toISOString(),
|
|
222
|
+
job_key: entry.jobKey,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
183
226
|
/** The parked `feature-escalation` user task, as `pollFeatureEscalations` observes it via
|
|
184
227
|
* `openUserTasks` (the open-task-scoped query — issue #294): the completable user-task key the pages
|
|
185
228
|
* drive an attributed answer against. Scoping to `state:"CREATED"` is what keeps a looping run — which
|