@nanobpm/nano-workforce 0.175.0 → 0.175.1
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 +6 -0
- package/app/feature.test.ts +141 -2
- package/app/feature.ts +84 -6
- package/app/terminalReaderGuard.test.ts +68 -39
- package/openapi.yaml +24 -1
- package/operations/startFeature.ts +19 -2
- package/package.json +1 -1
- package/pages/feature.page.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.175.1](https://github.com/nanobpm/nano-workforce/compare/v0.175.0...v0.175.1) (2026-09-02)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **intake:** route feature-run admission idempotency through derived_status ([#707](https://github.com/nanobpm/nano-workforce/issues/707)) ([eab2c9c](https://github.com/nanobpm/nano-workforce/commit/eab2c9ce3d4b22bad38da62170f13fda647eb21f)), closes [#503](https://github.com/nanobpm/nano-workforce/issues/503) [#503](https://github.com/nanobpm/nano-workforce/issues/503) [#503](https://github.com/nanobpm/nano-workforce/issues/503) [#704](https://github.com/nanobpm/nano-workforce/issues/704)
|
|
6
|
+
|
|
1
7
|
## [0.175.0](https://github.com/nanobpm/nano-workforce/compare/v0.174.1...v0.175.0) (2026-09-02)
|
|
2
8
|
|
|
3
9
|
### Features
|
package/app/feature.test.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// process variables (the single `task` slice + the base-branch brief).
|
|
8
8
|
import { after, test } from "node:test";
|
|
9
9
|
import { assertEquals } from "#test-assert";
|
|
10
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
10
11
|
import { FEATURE_PROCESS_ID, FEATURE_TERMINAL_STATUSES, featureTaskId, startFeature } from "./feature.ts";
|
|
11
12
|
|
|
12
13
|
// `startFeature` now fetches the issue title (issue #248) via the GitHub transport. Force the token
|
|
@@ -49,7 +50,11 @@ function memTable(rows: any[], key: string) {
|
|
|
49
50
|
|
|
50
51
|
function memData(stores: Record<string, { rows: any[]; key: string }>) {
|
|
51
52
|
return {
|
|
52
|
-
|
|
53
|
+
// Serve the ADR-0065 derived tracking VIEW (`feature_runs__tracking`) off the base store so the
|
|
54
|
+
// intake idempotency reader (which reads `derived_status`, issue #704) resolves against the same
|
|
55
|
+
// rows — a base row with no explicitly-seeded `derived_status` folds `derived_status := status`.
|
|
56
|
+
table: withTrackingViews((name: string, key: string) =>
|
|
57
|
+
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
53
58
|
} as any;
|
|
54
59
|
}
|
|
55
60
|
|
|
@@ -74,6 +79,8 @@ test("startFeature: inserts a running feature_runs row and persists the process
|
|
|
74
79
|
|
|
75
80
|
assertEquals(result.featureKey, "owner/repo#42");
|
|
76
81
|
assertEquals(result.processKey, "PI-9");
|
|
82
|
+
assertEquals(result.outcome, "started");
|
|
83
|
+
assertEquals(result.alreadyRunning, false);
|
|
77
84
|
const row = stores.feature_runs.rows[0];
|
|
78
85
|
assertEquals(row.feature_key, "owner/repo#42");
|
|
79
86
|
assertEquals(row.repo, "owner/repo");
|
|
@@ -214,6 +221,7 @@ test("startFeature: an already-running run short-circuits (no new instance)", as
|
|
|
214
221
|
const result = await startFeature(memData(stores), engine, PARSED, "main", false, false);
|
|
215
222
|
assertEquals(created, 0);
|
|
216
223
|
assertEquals("alreadyRunning" in result && (result as any).alreadyRunning, true);
|
|
224
|
+
assertEquals(result.outcome, "already-active");
|
|
217
225
|
assertEquals(result.processKey, "PI-OLD");
|
|
218
226
|
});
|
|
219
227
|
|
|
@@ -293,7 +301,138 @@ test("startFeature: an in-place restart clears a stale acknowledged_at (re-earn
|
|
|
293
301
|
assertEquals(row.acknowledged_at, null);
|
|
294
302
|
});
|
|
295
303
|
|
|
296
|
-
// Issue #
|
|
304
|
+
// Issue #704 (RED first): the feature-side twin of the #503 `submitPr`/epic re-admission wedges. Under
|
|
305
|
+
// urban 0.81.0 the `instanceTracking` reconciler is a SOURCE, not a writer: on cancel/terminate it no
|
|
306
|
+
// longer stamps the terminal `abandoned` onto base `feature_runs.status` — the terminal is recomputed
|
|
307
|
+
// on read as `feature_runs__tracking.derived_status`. A terminated run therefore keeps its base
|
|
308
|
+
// `status` frozen at its last worker transient (`running`/`escalated`/`awaiting_operator`) while
|
|
309
|
+
// `derived_status` reads `abandoned`. The intake idempotency reader MUST classify on `derived_status`,
|
|
310
|
+
// or a terminated run wedges `already-active` forever — a green success that dispatches NO instance.
|
|
311
|
+
//
|
|
312
|
+
// (a) present + TERMINATED: base row present, `status` frozen, `derived_status: "abandoned"` → the
|
|
313
|
+
// resubmit dispatches a FRESH instance and reports `started` with a new processKey.
|
|
314
|
+
test("startFeature: resubmits a derive-only-terminated run (base 'running', derived 'abandoned') — started, not already-active", async () => {
|
|
315
|
+
const stores = {
|
|
316
|
+
feature_runs: {
|
|
317
|
+
rows: [{
|
|
318
|
+
feature_key: "owner/repo#42",
|
|
319
|
+
repo: "owner/repo",
|
|
320
|
+
issue_number: 42,
|
|
321
|
+
status: "running", // base transient FROZEN — the reconciler no longer writes the terminal
|
|
322
|
+
derived_status: "abandoned", // ADR-0065 derive-only terminal
|
|
323
|
+
process_key: "PI-DEAD",
|
|
324
|
+
pr_key: null,
|
|
325
|
+
converge: 0,
|
|
326
|
+
auto_merge: 0,
|
|
327
|
+
}],
|
|
328
|
+
key: "feature_key",
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
let created = 0;
|
|
332
|
+
const engine = {
|
|
333
|
+
createInstance: () => {
|
|
334
|
+
created += 1;
|
|
335
|
+
return Promise.resolve({ processInstanceKey: "PI-FRESH" });
|
|
336
|
+
},
|
|
337
|
+
} as any;
|
|
338
|
+
const result = await startFeature(memData(stores), engine, PARSED, "main", false, false);
|
|
339
|
+
assertEquals(created, 1); // NOT wedged — a fresh incarnation was dispatched
|
|
340
|
+
assertEquals(result.outcome, "started");
|
|
341
|
+
assertEquals(result.alreadyRunning, false);
|
|
342
|
+
assertEquals(result.processKey, "PI-FRESH"); // a NEW instance key, not the dead PI-DEAD
|
|
343
|
+
const row = stores.feature_runs.rows[0];
|
|
344
|
+
assertEquals(row.status, "running"); // restarted in place
|
|
345
|
+
assertEquals(row.process_key, "PI-FRESH");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// (b) vanished instance (feature_runs row absent — a clean reset dropped it): the intake sees no prior
|
|
349
|
+
// run at all and inserts a fresh one. Resubmit dispatches a fresh instance and reports `started`.
|
|
350
|
+
// (The present-but-instance-state-vanished shape — where derived_status cannot fold — is #630's edge;
|
|
351
|
+
// this asserts the row-absent boundary is resubmittable here.)
|
|
352
|
+
test("startFeature: resubmits when the prior run row has vanished (row absent) — started", async () => {
|
|
353
|
+
const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
|
|
354
|
+
let created = 0;
|
|
355
|
+
const engine = {
|
|
356
|
+
createInstance: () => {
|
|
357
|
+
created += 1;
|
|
358
|
+
return Promise.resolve({ processInstanceKey: "PI-REBORN" });
|
|
359
|
+
},
|
|
360
|
+
} as any;
|
|
361
|
+
const result = await startFeature(memData(stores), engine, PARSED, "main", false, false);
|
|
362
|
+
assertEquals(created, 1);
|
|
363
|
+
assertEquals(result.outcome, "started");
|
|
364
|
+
assertEquals(result.processKey, "PI-REBORN");
|
|
365
|
+
assertEquals(stores.feature_runs.rows[0].process_key, "PI-REBORN");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// A genuinely-live prior run (non-terminal derived edge) still short-circuits `already-active` — a
|
|
369
|
+
// live run cannot be double-started.
|
|
370
|
+
test("startFeature: a non-terminal derived run still short-circuits (already-active, no new instance)", async () => {
|
|
371
|
+
const stores = {
|
|
372
|
+
feature_runs: {
|
|
373
|
+
rows: [{
|
|
374
|
+
feature_key: "owner/repo#42",
|
|
375
|
+
status: "running",
|
|
376
|
+
derived_status: "running", // still live
|
|
377
|
+
process_key: "PI-LIVE",
|
|
378
|
+
}],
|
|
379
|
+
key: "feature_key",
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
let created = 0;
|
|
383
|
+
const engine = {
|
|
384
|
+
createInstance: () => {
|
|
385
|
+
created += 1;
|
|
386
|
+
return Promise.resolve({ processInstanceKey: "PI-NEW" });
|
|
387
|
+
},
|
|
388
|
+
} as any;
|
|
389
|
+
const result = await startFeature(memData(stores), engine, PARSED, "main", false, false);
|
|
390
|
+
assertEquals(created, 0);
|
|
391
|
+
assertEquals(result.outcome, "already-active");
|
|
392
|
+
assertEquals(result.alreadyRunning, true);
|
|
393
|
+
assertEquals(result.processKey, "PI-LIVE");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// The silent-success contract (#704 fix #3): when the engine returns NO instance key the intake
|
|
397
|
+
// dispatched nothing — it must report `noop-terminal` (a distinct non-success), never a green
|
|
398
|
+
// `started`. The operation maps this to a 502 so the page renders "nothing started" distinctly.
|
|
399
|
+
test("startFeature: a start that dispatches no instance reports noop-terminal (not a green success)", async () => {
|
|
400
|
+
const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
|
|
401
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: null }) } as any;
|
|
402
|
+
const result = await startFeature(memData(stores), engine, PARSED, "main", false, false);
|
|
403
|
+
assertEquals(result.outcome, "noop-terminal");
|
|
404
|
+
assertEquals(result.processKey, null);
|
|
405
|
+
assertEquals(result.alreadyRunning, false);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// #704 follow-up (review): a `noop-terminal` start leaves a `feature_runs` row at base `running` with
|
|
409
|
+
// `process_key: null` — NO engine instance was ever dispatched. That phantom row's `derived_status`
|
|
410
|
+
// folds to the non-terminal base `running` (there is no terminated instance to fold to `abandoned`),
|
|
411
|
+
// so without the null-`process_key` admission guard a retry would short-circuit `already-active` and
|
|
412
|
+
// wedge resubmission forever. Assert the retry stays RESUBMITTABLE and re-dispatches.
|
|
413
|
+
test("startFeature: retry after a noop-terminal re-dispatches (a null process_key row is not already-active)", async () => {
|
|
414
|
+
const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
|
|
415
|
+
// Round 1: the engine dispatches nothing → noop-terminal, leaving a running/process_key:null row.
|
|
416
|
+
const noopEngine = { createInstance: () => Promise.resolve({ processInstanceKey: null }) } as any;
|
|
417
|
+
const first = await startFeature(memData(stores), noopEngine, PARSED, "main", false, false);
|
|
418
|
+
assertEquals(first.outcome, "noop-terminal");
|
|
419
|
+
assertEquals(stores.feature_runs.rows[0].status, "running");
|
|
420
|
+
assertEquals(stores.feature_runs.rows[0].process_key, null);
|
|
421
|
+
// Round 2: a healthy engine now dispatches — the retry must NOT wedge as already-active.
|
|
422
|
+
let created = 0;
|
|
423
|
+
const engine = {
|
|
424
|
+
createInstance: () => {
|
|
425
|
+
created += 1;
|
|
426
|
+
return Promise.resolve({ processInstanceKey: "PI-RETRY" });
|
|
427
|
+
},
|
|
428
|
+
} as any;
|
|
429
|
+
const second = await startFeature(memData(stores), engine, PARSED, "main", false, false);
|
|
430
|
+
assertEquals(created, 1);
|
|
431
|
+
assertEquals(second.outcome, "started");
|
|
432
|
+
assertEquals(second.alreadyRunning, false);
|
|
433
|
+
assertEquals(second.processKey, "PI-RETRY");
|
|
434
|
+
});
|
|
435
|
+
|
|
297
436
|
// `title` — the fetched issue title when available, else the `owner/repo#N` key — on BOTH the insert
|
|
298
437
|
// (new run) and update (in-place restart) paths, so the title-led grid never renders a blank cell.
|
|
299
438
|
test("startFeature: coalesces title to the key when the fetch yields nothing (insert path)", async () => {
|
package/app/feature.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
18
18
|
import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
|
|
19
19
|
import { coalesceTitle, fetchIssueTitle } from "./github.ts";
|
|
20
|
+
import { derivedTrackingTable } from "./instanceTracking.ts";
|
|
20
21
|
import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
|
|
21
22
|
import type { ReadinessProbe } from "./readiness.ts";
|
|
22
23
|
import { repoEnvelopeVars } from "./repoEnvelope.ts";
|
|
@@ -235,6 +236,50 @@ export const FEATURE_BLOCKED_ELEMENT = "feature-blocked";
|
|
|
235
236
|
* that reproduces the reconciler bypass). */
|
|
236
237
|
export const featureRuns = (data: DataLayer) => data.table<FeatureRun>("feature_runs", "feature_key");
|
|
237
238
|
|
|
239
|
+
/** A `feature_runs` row as seen through its derived tracking VIEW (`feature_runs__tracking`): the base
|
|
240
|
+
* columns plus urban's ADR-0065 `derived_status`, which FOLDS the reconciler's terminal edge
|
|
241
|
+
* (out-of-band terminate / in-app cancel → `abandoned`) over the worker-owned transient. Since urban
|
|
242
|
+
* 0.81.0 the `instanceTracking` reconciler is a SOURCE, not a writer: it no longer stamps the terminal
|
|
243
|
+
* onto base `status` on cancel/terminate — the terminal is recomputed on read as `derived_status`. */
|
|
244
|
+
export type TrackedFeatureRun = FeatureRun & { derived_status: string };
|
|
245
|
+
|
|
246
|
+
/** Read-only accessor over the feature-run derived tracking VIEW (`feature_runs__tracking`). Use this —
|
|
247
|
+
* and read `derived_status`, NOT the base `status` — for the intake/admission idempotency
|
|
248
|
+
* classification (issue #704), so a run whose engine instance was terminated out-of-band (base row
|
|
249
|
+
* frozen at its last transient `running`/`escalated`/`awaiting_operator`) is correctly seen as
|
|
250
|
+
* `abandoned` and RESUBMITTABLE. The view name + `derived_status` column resolve through
|
|
251
|
+
* `derivedTrackingTable` (app/instanceTracking.ts), never a hard-coded name — mirroring `prsTracking`
|
|
252
|
+
* (app/service.ts) and `plansTracking` (app/plan.ts). Writes stay on `featureRuns`. */
|
|
253
|
+
export const featureRunsTracking = (data: DataLayer) =>
|
|
254
|
+
derivedTrackingTable<TrackedFeatureRun>(data, "feature_runs", "feature_key");
|
|
255
|
+
|
|
256
|
+
/** The discriminated outcome of a feature-run intake (issue #704). It distinguishes a genuine dispatch
|
|
257
|
+
* from a short-circuit and from a no-op, so a submit that started NOTHING can never render as a bare
|
|
258
|
+
* green success:
|
|
259
|
+
* - `started` — a fresh engine instance was dispatched (`processKey` set).
|
|
260
|
+
* - `already-active` — a non-terminal prior run for the same issue is still live, so the start
|
|
261
|
+
* short-circuited; no new instance was created and `processKey` is the live run's.
|
|
262
|
+
* - `noop-terminal` — the intake attempted a start but the engine returned NO instance key, so
|
|
263
|
+
* nothing was dispatched. Surfaced by the operation as a distinct non-success (a submit that
|
|
264
|
+
* dispatches no instance is not "Done"). */
|
|
265
|
+
export type FeatureStartOutcome = "started" | "already-active" | "noop-terminal";
|
|
266
|
+
|
|
267
|
+
/** The result of {@link startFeature} — a discriminated intake outcome (issue #704). `alreadyRunning`
|
|
268
|
+
* is retained as a back-compat flag for existing callers/telemetry (true iff `outcome` is
|
|
269
|
+
* `already-active`). A `type` alias (not an `interface`) because an object-literal type alias — with a
|
|
270
|
+
* fully-known, final set of properties — is assignable to the generated OpenAPI response shape's index
|
|
271
|
+
* signature, whereas an `interface` (open to declaration-merging) is not; this mirrors the inferred
|
|
272
|
+
* sibling results. */
|
|
273
|
+
export type StartFeatureResult = {
|
|
274
|
+
featureKey: string;
|
|
275
|
+
outcome: FeatureStartOutcome;
|
|
276
|
+
/** The dispatched instance key (`started`), the live run's key (`already-active`), or null
|
|
277
|
+
* (`noop-terminal`). */
|
|
278
|
+
processKey: string | null;
|
|
279
|
+
/** True iff a non-terminal prior run short-circuited the start (`outcome === "already-active"`). */
|
|
280
|
+
alreadyRunning: boolean;
|
|
281
|
+
};
|
|
282
|
+
|
|
238
283
|
/** The deterministic task id for a single-issue run — the implementation agent branches
|
|
239
284
|
* `feat/<task.id>` (see resources/prompts/feature.md), so it MUST be derivable from the issue alone
|
|
240
285
|
* and stable across a resume. The PR is opened on the target repo, so the issue number
|
|
@@ -257,7 +302,7 @@ export async function startFeature(
|
|
|
257
302
|
autoMerge: boolean,
|
|
258
303
|
customInstructions: string | null = null,
|
|
259
304
|
readiness: FeatureReadinessOptions = {},
|
|
260
|
-
) {
|
|
305
|
+
): Promise<StartFeatureResult> {
|
|
261
306
|
// Intake-time readiness gate (issue #295): the probes the run must satisfy before it implements,
|
|
262
307
|
// and the bound its preflight escalation timers fire off. Both are load-bearing together —
|
|
263
308
|
// `pr.readiness-probe` rejects a blank `probeTimeout` and the preflight timers read `=probeTimeout`
|
|
@@ -286,9 +331,38 @@ export async function startFeature(
|
|
|
286
331
|
? customInstructions.trim()
|
|
287
332
|
: null;
|
|
288
333
|
const table = featureRuns(data);
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
334
|
+
// ADR-0065: classify "already running" on the DERIVED terminal edge, not the base transient. A
|
|
335
|
+
// feature run whose engine instance was terminated out-of-band (or by an ordinary in-app cancel —
|
|
336
|
+
// derive-only under urban 0.81.0) has a base row frozen at its last worker transient
|
|
337
|
+
// (`running`/`escalated`/`awaiting_operator`) but a `feature_runs__tracking.derived_status` of
|
|
338
|
+
// `abandoned`. Reading the base `status` here wedged a terminated run `alreadyRunning` forever —
|
|
339
|
+
// returning a green success while dispatching NO instance (issue #704, the feature-side twin of the
|
|
340
|
+
// `submitPr` / epic re-admission wedges #503 fixed; this intake reader was the one #503 omitted).
|
|
341
|
+
// Route the idempotency gate through the derived view so a terminated run is correctly seen terminal
|
|
342
|
+
// and RESUBMITTABLE; a non-terminal derived edge still short-circuits (a genuinely-live run cannot
|
|
343
|
+
// be double-started). ONE read through the tracking VIEW serves both this classification and the
|
|
344
|
+
// insert-vs-update decision below: it re-exports every base column (so it doubles as the "exists?"
|
|
345
|
+
// check and the `process_key` source) plus `derived_status`, so a second base-table round trip is
|
|
346
|
+
// redundant.
|
|
347
|
+
const trackedExisting = await featureRunsTracking(data).get(parsed.planKey);
|
|
348
|
+
// A row with a null `process_key` never had an engine instance dispatched, so it is NEVER a live
|
|
349
|
+
// run — treat it as non-active for admission. This is load-bearing for the `noop-terminal` path
|
|
350
|
+
// (issue #704): a start the engine accepted without returning an instance key leaves a base row at
|
|
351
|
+
// `running`/`process_key: null` whose `derived_status` folds to the non-terminal base `running`
|
|
352
|
+
// (there is no terminated instance to fold to `abandoned`). Without this guard that phantom row
|
|
353
|
+
// would short-circuit every retry as `already-active`, wedging resubmission forever even though
|
|
354
|
+
// nothing was ever dispatched.
|
|
355
|
+
if (
|
|
356
|
+
trackedExisting &&
|
|
357
|
+
trackedExisting.process_key != null &&
|
|
358
|
+
!FEATURE_TERMINAL_STATUSES.some((s) => s === trackedExisting.derived_status)
|
|
359
|
+
) {
|
|
360
|
+
return {
|
|
361
|
+
featureKey: parsed.planKey,
|
|
362
|
+
outcome: "already-active",
|
|
363
|
+
processKey: trackedExisting.process_key,
|
|
364
|
+
alreadyRunning: true,
|
|
365
|
+
};
|
|
292
366
|
}
|
|
293
367
|
const base = normalizeBaseBranch(baseBranch);
|
|
294
368
|
const ts = now();
|
|
@@ -300,7 +374,7 @@ export async function startFeature(
|
|
|
300
374
|
await fetchIssueTitle(parsed.repo, parsed.number, process.env.GITHUB_TOKEN ?? ""),
|
|
301
375
|
parsed.planKey,
|
|
302
376
|
);
|
|
303
|
-
if (
|
|
377
|
+
if (trackedExisting) {
|
|
304
378
|
await table.update(parsed.planKey, {
|
|
305
379
|
status: "running",
|
|
306
380
|
base_branch: base,
|
|
@@ -425,6 +499,10 @@ export async function startFeature(
|
|
|
425
499
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
|
426
500
|
if (processKey != null) {
|
|
427
501
|
await table.update(parsed.planKey, { process_key: processKey, updated_at: now() });
|
|
502
|
+
return { featureKey: parsed.planKey, outcome: "started", processKey, alreadyRunning: false };
|
|
428
503
|
}
|
|
429
|
-
|
|
504
|
+
// The engine accepted the start but returned NO instance key — nothing was actually dispatched.
|
|
505
|
+
// Report a discriminated no-op (issue #704) so the caller can surface it as a distinct non-success
|
|
506
|
+
// instead of a bare green "Done": a submit that dispatches no instance is not "started".
|
|
507
|
+
return { featureKey: parsed.planKey, outcome: "noop-terminal", processKey: null, alreadyRunning: false };
|
|
430
508
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Class guard for the ADR-0065 terminal-edge reader migration (
|
|
1
|
+
// Class guard for the ADR-0065 terminal-edge reader migration (issues #503, #704).
|
|
2
2
|
//
|
|
3
3
|
// Since ADR-0065 (`@nanobpm/urban@0.81.0`) the `instanceTracking` reconciler is a SOURCE, not a
|
|
4
4
|
// writer: on cancel/terminate it feeds urban's instance projection and the terminal edge
|
|
@@ -6,14 +6,20 @@
|
|
|
6
6
|
// the terminal (`abandoned`/`failed`/`reviewed`) onto the base `status` column. A classifying reader
|
|
7
7
|
// that inspects the BASE `status` column of a DERIVE-ONLY tracked table therefore sees a row frozen at
|
|
8
8
|
// its last worker-owned transient after the instance ends → phantom-active / wedged-idempotency bugs
|
|
9
|
-
// (the #497 / #503 class).
|
|
9
|
+
// (the #497 / #503 / #704 class).
|
|
10
10
|
//
|
|
11
|
-
// This is a SOURCE-SCAN guard over the defect CLASS, not a single instance
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
11
|
+
// This is a SOURCE-SCAN guard over the defect CLASS, not a single instance. It is PARAMETRIZED over
|
|
12
|
+
// the three derive-only tracked tables and their admission/idempotency/classifier readers
|
|
13
|
+
// (derivation-over-duplication in the test itself, so the whole class is unrepresentable):
|
|
14
|
+
// - `pull_requests` → service.ts, `TERMINAL_STATUSES` (submitPr idempotency, activePrs, incidents,
|
|
15
|
+
// merge lanes, wave gates)
|
|
16
|
+
// - `plans` → plan.ts, `PLAN_TERMINAL_STATUSES` (startPlan re-admission, active-by-base)
|
|
17
|
+
// - `feature_runs` → feature.ts, `FEATURE_TERMINAL_STATUSES` (startFeature INTAKE idempotency — the
|
|
18
|
+
// reader #503 omitted, closed by #704)
|
|
19
|
+
// Every terminal-set classification (both the `.includes(x)` and `.some((s) => s === x)` forms) MUST
|
|
20
|
+
// read the DERIVED effective status (`.derived_status`), never the frozen base `.status`. A future
|
|
21
|
+
// reader — on ANY of the three tables — that silently re-drifts onto the base column fails here; the
|
|
22
|
+
// pre-#704 feature intake (`FEATURE_TERMINAL_STATUSES.includes(existing.status)`) would have been red.
|
|
17
23
|
//
|
|
18
24
|
// Worker-owned terminals that PASS THROUGH the derive edge unchanged (`merged`) are exempt: a base
|
|
19
25
|
// `=== "merged"` read is legitimate (see `isDepMerged` / `classifyWaveTarget` / `mergeLaneDecisionForPr`
|
|
@@ -36,23 +42,61 @@ function stripComments(src: string): string {
|
|
|
36
42
|
return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
37
43
|
}
|
|
38
44
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
);
|
|
50
|
-
assert(
|
|
51
|
-
!/[A-Za-z0-9_)\]]\.status\b/.test(arg),
|
|
52
|
-
`TERMINAL_STATUSES.includes(${arg}) still reads a base \`.status\` — the terminal edge is derive-only (#503)`,
|
|
53
|
-
);
|
|
45
|
+
/** Every expression a `<SET>_TERMINAL_STATUSES` classifies, across BOTH idioms the app uses:
|
|
46
|
+
* `SET.includes(<expr>)` and `SET.some((s) => s === <expr>)` (and the mirrored `<expr> === s`). One
|
|
47
|
+
* extractor for both forms so a reader can't dodge the guard by switching idiom. For the `.some`
|
|
48
|
+
* form we capture the CLASSIFIED OPERAND — the side of `===` that is NOT the arrow parameter — on
|
|
49
|
+
* either orientation, and assert against that operand rather than the whole arrow body, so an
|
|
50
|
+
* incidental `.derived_status` reference elsewhere in the body can't mask a base-`.status`
|
|
51
|
+
* classification. */
|
|
52
|
+
function classifiedExprs(code: string, setName: string): string[] {
|
|
53
|
+
const exprs: string[] = [];
|
|
54
|
+
for (const m of code.matchAll(new RegExp(`${setName}\\.includes\\(([^)]*)\\)`, "g"))) {
|
|
55
|
+
exprs.push(m[1]);
|
|
54
56
|
}
|
|
55
|
-
})
|
|
57
|
+
for (const m of code.matchAll(new RegExp(`${setName}\\.some\\(\\(\\s*(\\w+)\\s*\\)\\s*=>\\s*([^)]*)\\)`, "g"))) {
|
|
58
|
+
const param = m[1]; // the arrow parameter, e.g. `s`
|
|
59
|
+
const body = m[2]; // `s === <expr>` or the mirrored `<expr> === s`
|
|
60
|
+
const sides = body.split("===").map((x) => x.trim());
|
|
61
|
+
// Capture the operand compared against the loop parameter, on either side of `===`; fall back to
|
|
62
|
+
// the whole body for any shape we don't recognise so the guard errs toward stricter, not looser.
|
|
63
|
+
if (sides.length === 2 && (sides[0] === param || sides[1] === param)) {
|
|
64
|
+
exprs.push(sides[0] === param ? sides[1] : sides[0]);
|
|
65
|
+
} else {
|
|
66
|
+
exprs.push(body);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return exprs;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The derive-only tracked tables and the reader module + terminal-status set that classifies each.
|
|
73
|
+
* ONE registry drives the whole guard so adding a fourth derive-only admission reader is a one-line
|
|
74
|
+
* change, never a copy-pasted test. */
|
|
75
|
+
const DERIVE_ONLY_READERS = [
|
|
76
|
+
{ table: "pull_requests", file: "service.ts", set: "TERMINAL_STATUSES" },
|
|
77
|
+
{ table: "plans", file: "plan.ts", set: "PLAN_TERMINAL_STATUSES" },
|
|
78
|
+
{ table: "feature_runs", file: "feature.ts", set: "FEATURE_TERMINAL_STATUSES" },
|
|
79
|
+
] as const;
|
|
80
|
+
|
|
81
|
+
for (const { table, file, set } of DERIVE_ONLY_READERS) {
|
|
82
|
+
test(`class guard: every ${set} classification (${table} admission/idempotency) reads derived_status, not base status`, () => {
|
|
83
|
+
const code = stripComments(SRC(file));
|
|
84
|
+
const exprs = classifiedExprs(code, set);
|
|
85
|
+
assert(exprs.length > 0, `expected ${set} classifications in ${file}`);
|
|
86
|
+
for (const expr of exprs) {
|
|
87
|
+
assert(
|
|
88
|
+
/\.derived_status\b/.test(expr),
|
|
89
|
+
`${set} classifies on \`${expr}\` — the BASE status of the derive-only tracked table \`${table}\`. ` +
|
|
90
|
+
`Route it through the derived tracking view and read \`.derived_status\` (ADR-0065, #503/#704)`,
|
|
91
|
+
);
|
|
92
|
+
assert(
|
|
93
|
+
!/[A-Za-z0-9_)\]]\.status\b/.test(expr),
|
|
94
|
+
`${set} classifies on \`${expr}\` which still reads a base \`.status\` — the terminal edge is ` +
|
|
95
|
+
`derive-only for \`${table}\` (ADR-0065, #503/#704)`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
56
100
|
|
|
57
101
|
test("class guard: no base `.status === ABANDONED_STATUS` / `.status === \"abandoned\"` read classification in service.ts", () => {
|
|
58
102
|
const code = stripComments(SRC("service.ts"));
|
|
@@ -71,21 +115,6 @@ test("class guard: no base `.status === ABANDONED_STATUS` / `.status === \"aband
|
|
|
71
115
|
);
|
|
72
116
|
});
|
|
73
117
|
|
|
74
|
-
test("class guard: every PLAN_TERMINAL_STATUSES classification in plan.ts reads derived_status, not base status", () => {
|
|
75
|
-
const code = stripComments(SRC("plan.ts"));
|
|
76
|
-
// Match the `.some((s) => s === <ref>)` classification form used at both admission sites.
|
|
77
|
-
const calls = [...code.matchAll(/PLAN_TERMINAL_STATUSES\.some\(\([^)]*\)\s*=>\s*[^)]*===\s*([A-Za-z0-9_.]+)\)/g)];
|
|
78
|
-
assert(calls.length > 0, "expected PLAN_TERMINAL_STATUSES classifications in plan.ts");
|
|
79
|
-
for (const m of calls) {
|
|
80
|
-
const ref = m[1];
|
|
81
|
-
assert(
|
|
82
|
-
/\.derived_status$/.test(ref),
|
|
83
|
-
`PLAN_TERMINAL_STATUSES classification reads \`${ref}\` — route it through plansTracking and read ` +
|
|
84
|
-
`\`.derived_status\` so a derive-only-terminated epic is seen terminal (ADR-0065, #503)`,
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
|
|
89
118
|
test("class guard: the feature read model classifies on derived_status, never the base status column", () => {
|
|
90
119
|
// The feature history read model (app/featureReadModel.ts) buckets a run's pipeline `stage`/
|
|
91
120
|
// `list_bucket` off its status. Under ADR-0065 that must be the terminal-folded `derived_status`
|
package/openapi.yaml
CHANGED
|
@@ -1281,15 +1281,29 @@ components:
|
|
|
1281
1281
|
type: object
|
|
1282
1282
|
required:
|
|
1283
1283
|
- featureKey
|
|
1284
|
+
- outcome
|
|
1284
1285
|
properties:
|
|
1285
1286
|
featureKey:
|
|
1286
1287
|
type: string
|
|
1288
|
+
outcome:
|
|
1289
|
+
type: string
|
|
1290
|
+
enum:
|
|
1291
|
+
- started
|
|
1292
|
+
- already-active
|
|
1293
|
+
- noop-terminal
|
|
1294
|
+
description: >-
|
|
1295
|
+
Discriminated intake outcome (issue #704). `started` — a fresh engine instance was
|
|
1296
|
+
dispatched (`processKey` set). `already-active` — a non-terminal prior run for this issue is
|
|
1297
|
+
still live, so the start short-circuited (no new instance; `processKey` is the live run's).
|
|
1298
|
+
`noop-terminal` — the engine returned no instance key, so NOTHING was dispatched; the
|
|
1299
|
+
operation surfaces this as a 502 (never on a 202 body), so a submit that dispatched no
|
|
1300
|
+
instance is never reported as success.
|
|
1287
1301
|
processKey:
|
|
1288
1302
|
type: string
|
|
1289
1303
|
nullable: true
|
|
1290
1304
|
alreadyRunning:
|
|
1291
1305
|
type: boolean
|
|
1292
|
-
description: True
|
|
1306
|
+
description: True iff a non-terminal feature run for this issue already existed and short-circuited the start (outcome `already-active`); no new instance was started.
|
|
1293
1307
|
EpicSetStart:
|
|
1294
1308
|
description: >-
|
|
1295
1309
|
The set/batch admission request body (issue #292, slice S2). Submits a SET of epics plus the
|
|
@@ -5618,6 +5632,15 @@ paths:
|
|
|
5618
5632
|
application/json:
|
|
5619
5633
|
schema:
|
|
5620
5634
|
$ref: "#/components/schemas/ErrorBody"
|
|
5635
|
+
"502":
|
|
5636
|
+
description: >-
|
|
5637
|
+
The engine accepted the start but returned no instance key — nothing was dispatched (issue
|
|
5638
|
+
#704). A submit that starts no instance is surfaced here as a distinct non-success, never a
|
|
5639
|
+
green 202.
|
|
5640
|
+
content:
|
|
5641
|
+
application/json:
|
|
5642
|
+
schema:
|
|
5643
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
5621
5644
|
/actions/message:
|
|
5622
5645
|
post:
|
|
5623
5646
|
operationId: postMessage
|
|
@@ -177,14 +177,31 @@ export default defineOperation("startFeature", async ({ body }, app) => {
|
|
|
177
177
|
customInstructions,
|
|
178
178
|
{ probes: readiness.probes, probeTimeout: readiness.probeTimeout, probePollEvery: readiness.probePollEvery },
|
|
179
179
|
);
|
|
180
|
-
app.log.info("feature run
|
|
180
|
+
app.log.info("feature run intake", {
|
|
181
181
|
featureKey: parsed.planKey,
|
|
182
182
|
requestedBaseBranch: normalizedBase,
|
|
183
183
|
converge,
|
|
184
184
|
autoMerge,
|
|
185
185
|
hasCustomInstructions: typeof customInstructions === "string" && customInstructions.trim() !== "",
|
|
186
186
|
readinessProbes: readiness.probes.length,
|
|
187
|
-
|
|
187
|
+
outcome: result.outcome,
|
|
188
|
+
alreadyRunning: result.alreadyRunning,
|
|
188
189
|
});
|
|
190
|
+
// Discriminated intake outcome (issue #704): a `noop-terminal` means the engine accepted the start
|
|
191
|
+
// but returned no instance key, so NOTHING was dispatched. That is not a success — surface it as a
|
|
192
|
+
// 502 so the feature page renders "nothing started" distinctly (a red error banner), never a bare
|
|
193
|
+
// green success for a submit that started no instance. `started` / `already-active` are both
|
|
194
|
+
// legitimate 202s (the latter short-circuited a genuinely-live run).
|
|
195
|
+
if (result.outcome === "noop-terminal") {
|
|
196
|
+
app.log.error("feature run intake dispatched no engine instance", { featureKey: parsed.planKey });
|
|
197
|
+
return {
|
|
198
|
+
status: 502,
|
|
199
|
+
body: {
|
|
200
|
+
error:
|
|
201
|
+
`feature run for ${parsed.planKey} started no engine instance — the engine returned no ` +
|
|
202
|
+
`instance key, so nothing was dispatched. Retry, or check the engine.`,
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
189
206
|
return { status: 202, body: result };
|
|
190
207
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.175.
|
|
3
|
+
"version": "0.175.1",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
package/pages/feature.page.json
CHANGED
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"props": {
|
|
85
85
|
"title": "Implement one issue",
|
|
86
86
|
"submitLabel": "Implement & raise PR",
|
|
87
|
-
"action": { "path": "/app/api/actions/start/feature", "body": "{{form}}" },
|
|
87
|
+
"action": { "path": "/app/api/actions/start/feature", "body": "{{form}}", "successLabel": "Feature run dispatched \u2014 track it in the runs below. (A run already live for this issue is re-used, not double-started; if nothing is dispatched you\u2019ll see an error here, never a silent success.)" },
|
|
88
88
|
"fields": [
|
|
89
89
|
{ "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text", "required": true, "requiredMessage": "An issue reference is required (owner/repo#123 or an issue URL)" },
|
|
90
90
|
{ "key": "baseBranch", "label": "Base branch: the branch the PR targets, e.g. main. A missing epic/* branch is auto-created off default HEAD; a non-epic/* branch must already exist.", "type": "text", "required": true, "requiredMessage": "Name the branch the PR targets (e.g. main or epic/agent-protocol)" },
|