@nanobpm/nano-workforce 0.131.1 → 0.133.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 +12 -0
- package/app/deliveryConnector.test.ts +19 -0
- package/app/deliveryConnector.ts +50 -10
- package/app/deliveryGraphCompiler.test.ts +23 -0
- package/app/lineage.test.ts +146 -0
- package/app/lineage.ts +100 -3
- package/app/migration079.test.ts +153 -0
- package/app/service.test.ts +28 -1
- package/app/service.ts +15 -0
- package/db/migrations/079_lineage_thread_view_delivery.sql +65 -0
- package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
- package/docs/agent-guide.md +47 -1
- package/openapi.yaml +2 -2
- package/package.json +1 -1
- package/pages/lineage.page.json +1 -1
- package/workers/delivery-connector/worker.test.ts +239 -1
- package/workers/delivery-connector/worker.ts +87 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Read-model guard for migration 079's extended `lineage_thread_view` VIEW (issue #498: surface
|
|
2
|
+
// delivery-graph runs in the Lineage tab as a fan-in parent thread). Mirrors app/migration064.test.ts:
|
|
3
|
+
// apply the migration to a real in-memory SQLite DB and assert the VIEW's output over sample rows —
|
|
4
|
+
// so this exercises the real view, not a re-implementation.
|
|
5
|
+
//
|
|
6
|
+
// The extended view adds a third origin arm: a root that matches a `delivery_graph_runs.run_key`
|
|
7
|
+
// derives `kind = 'delivery'`, `title` from the run, and a NULL `issue_url` (a run is keyed by
|
|
8
|
+
// run_key/digest, not a GitHub issue). The epic/feature/pr arms must keep behaving exactly as 064's
|
|
9
|
+
// view did (precedence unchanged).
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { DatabaseSync } from "node:sqlite";
|
|
12
|
+
import { test } from "node:test";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { assertEquals } from "#test-assert";
|
|
15
|
+
|
|
16
|
+
const MIGRATION_064 = fileURLToPath(new URL("../db/migrations/064_lineage_thread_view.sql", import.meta.url));
|
|
17
|
+
const MIGRATION_079 = fileURLToPath(
|
|
18
|
+
new URL("../db/migrations/079_lineage_thread_view_delivery.sql", import.meta.url),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
/** A DB with the base shapes the view reads (`lineage_threads`, `plans`, `feature_runs`,
|
|
22
|
+
* `delivery_graph_runs`) plus 064 then 079 applied in order — so this exercises the real DROP VIEW +
|
|
23
|
+
* re-CREATE the migration performs, not just the final definition. */
|
|
24
|
+
function viewDb(): DatabaseSync {
|
|
25
|
+
const db = new DatabaseSync(":memory:");
|
|
26
|
+
db.exec(
|
|
27
|
+
`CREATE TABLE lineage_threads (
|
|
28
|
+
root_request_key TEXT PRIMARY KEY, title TEXT, stage TEXT,
|
|
29
|
+
stage_label TEXT, process_key TEXT, pr_keys TEXT, pr_count INTEGER, active INTEGER,
|
|
30
|
+
created_at TEXT, updated_at TEXT);
|
|
31
|
+
CREATE TABLE plans (plan_key TEXT PRIMARY KEY, title TEXT, issue_url TEXT);
|
|
32
|
+
CREATE TABLE feature_runs (feature_key TEXT PRIMARY KEY, title TEXT, issue_url TEXT);
|
|
33
|
+
CREATE TABLE delivery_graph_runs (run_key TEXT PRIMARY KEY, title TEXT, phase TEXT, status TEXT);`,
|
|
34
|
+
);
|
|
35
|
+
db.exec(readFileSync(MIGRATION_064, "utf8"));
|
|
36
|
+
db.exec(readFileSync(MIGRATION_079, "utf8"));
|
|
37
|
+
return db;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Insert a `lineage_threads` row exactly as `pollLineage` denormalises one (post-072 schema — no
|
|
41
|
+
* `kind`/`issue_url` columns; the view derives both from the origin joins). */
|
|
42
|
+
function addThread(
|
|
43
|
+
db: DatabaseSync,
|
|
44
|
+
row: {
|
|
45
|
+
root_request_key: string;
|
|
46
|
+
title: string | null;
|
|
47
|
+
stage: string;
|
|
48
|
+
stage_label: string | null;
|
|
49
|
+
process_key: string | null;
|
|
50
|
+
pr_keys: string | null;
|
|
51
|
+
pr_count: number;
|
|
52
|
+
active: number;
|
|
53
|
+
},
|
|
54
|
+
): void {
|
|
55
|
+
db.prepare(
|
|
56
|
+
`INSERT INTO lineage_threads (root_request_key, title, stage, stage_label,
|
|
57
|
+
process_key, pr_keys, pr_count, active, created_at, updated_at)
|
|
58
|
+
VALUES (@root_request_key, @title, @stage, @stage_label, @process_key,
|
|
59
|
+
@pr_keys, @pr_count, @active, 't0', 't1')`,
|
|
60
|
+
).run(row);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test("lineage_thread_view derives kind/title/NULL issue_url for a delivery-graph thread from the run origin", () => {
|
|
64
|
+
const db = viewDb();
|
|
65
|
+
db.prepare(
|
|
66
|
+
"INSERT INTO delivery_graph_runs (run_key, title, phase, status) VALUES (?, ?, ?, ?)",
|
|
67
|
+
).run("dg-abc123", "Ship widget across repos", "Parked on human node: manual OTP publish", "running");
|
|
68
|
+
// pollLineage wrote the fan-in run's procedural frontier onto lineage_threads, keyed on run_key.
|
|
69
|
+
addThread(db, {
|
|
70
|
+
root_request_key: "dg-abc123",
|
|
71
|
+
title: "Ship widget across repos",
|
|
72
|
+
stage: "converging",
|
|
73
|
+
stage_label: "Parked on human node: manual OTP publish",
|
|
74
|
+
process_key: "P-dg",
|
|
75
|
+
pr_keys: '["a/b#1","c/d#9"]',
|
|
76
|
+
pr_count: 2,
|
|
77
|
+
active: 1,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const v = db
|
|
81
|
+
.prepare("SELECT * FROM lineage_thread_view WHERE root_request_key = ?")
|
|
82
|
+
.get("dg-abc123") as Record<string, unknown>;
|
|
83
|
+
// Derived from the delivery_graph_runs join.
|
|
84
|
+
assertEquals(v.kind, "delivery");
|
|
85
|
+
assertEquals(v.title, "Ship widget across repos");
|
|
86
|
+
// A run is keyed by run_key/digest, not a GitHub issue — issue_url is always NULL.
|
|
87
|
+
assertEquals(v.issue_url, null);
|
|
88
|
+
// Procedural frontier columns pass through unchanged from lineage_threads (the run's derived phase).
|
|
89
|
+
assertEquals(v.stage, "converging");
|
|
90
|
+
assertEquals(v.stage_label, "Parked on human node: manual OTP publish");
|
|
91
|
+
assertEquals(v.process_key, "P-dg");
|
|
92
|
+
assertEquals(v.pr_keys, '["a/b#1","c/d#9"]');
|
|
93
|
+
assertEquals(v.pr_count, 2);
|
|
94
|
+
assertEquals(v.active, 1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("lineage_thread_view renders a delivery thread with no PR landed yet (empty member set, run phase)", () => {
|
|
98
|
+
const db = viewDb();
|
|
99
|
+
db.prepare(
|
|
100
|
+
"INSERT INTO delivery_graph_runs (run_key, title, phase, status) VALUES (?, ?, ?, ?)",
|
|
101
|
+
).run("dg-empty", "Fresh run", "Running", "running");
|
|
102
|
+
addThread(db, {
|
|
103
|
+
root_request_key: "dg-empty",
|
|
104
|
+
title: "Fresh run",
|
|
105
|
+
stage: "implementing",
|
|
106
|
+
stage_label: "Running",
|
|
107
|
+
process_key: "P-e",
|
|
108
|
+
pr_keys: "[]",
|
|
109
|
+
pr_count: 0,
|
|
110
|
+
active: 1,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const v = db
|
|
114
|
+
.prepare("SELECT kind, title, issue_url, stage_label, pr_count FROM lineage_thread_view WHERE root_request_key = ?")
|
|
115
|
+
.get("dg-empty") as Record<string, unknown>;
|
|
116
|
+
assertEquals(v.kind, "delivery");
|
|
117
|
+
assertEquals(v.title, "Fresh run");
|
|
118
|
+
assertEquals(v.issue_url, null);
|
|
119
|
+
assertEquals(v.stage_label, "Running");
|
|
120
|
+
assertEquals(v.pr_count, 0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("lineage_thread_view keeps the epic/feature/pr arms unchanged after the delivery arm is added", () => {
|
|
124
|
+
const db = viewDb();
|
|
125
|
+
db.prepare("INSERT INTO plans (plan_key, title, issue_url) VALUES ('o/r#2', 'Epic', 'u-epic')").run();
|
|
126
|
+
db.prepare("INSERT INTO feature_runs (feature_key, title, issue_url) VALUES ('o/r#1', 'Feat', 'u-feat')").run();
|
|
127
|
+
const rows = [
|
|
128
|
+
{ root_request_key: "o/r#2", kind: "epic", title: "Epic", issue_url: "u-epic" },
|
|
129
|
+
{ root_request_key: "o/r#1", kind: "feature", title: "Feat", issue_url: "u-feat" },
|
|
130
|
+
{ root_request_key: "o/r#30", kind: "pr", title: "PR", issue_url: null },
|
|
131
|
+
];
|
|
132
|
+
for (const r of rows) {
|
|
133
|
+
addThread(db, {
|
|
134
|
+
root_request_key: r.root_request_key,
|
|
135
|
+
title: r.title,
|
|
136
|
+
stage: "opened",
|
|
137
|
+
stage_label: "Opened",
|
|
138
|
+
process_key: null,
|
|
139
|
+
pr_keys: "[]",
|
|
140
|
+
pr_count: 1,
|
|
141
|
+
active: 1,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const r of rows) {
|
|
146
|
+
const v = db
|
|
147
|
+
.prepare("SELECT kind, title, issue_url FROM lineage_thread_view WHERE root_request_key = ?")
|
|
148
|
+
.get(r.root_request_key) as Record<string, unknown>;
|
|
149
|
+
assertEquals(v.kind, r.kind);
|
|
150
|
+
assertEquals(v.title, r.title);
|
|
151
|
+
assertEquals(v.issue_url, r.issue_url);
|
|
152
|
+
}
|
|
153
|
+
});
|
package/app/service.test.ts
CHANGED
|
@@ -11,7 +11,9 @@ import { memDataFor } from "../test/worldDb.ts";
|
|
|
11
11
|
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
12
12
|
import { DurableResumeRegistry } from "./durableResume.ts";
|
|
13
13
|
import { WorldStore } from "./world/index.ts";
|
|
14
|
-
import { abandonClosedPr, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
14
|
+
import { abandonClosedPr, isPrSettled, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
15
|
+
import { trackingTargetFor } from "./instanceTracking.ts";
|
|
16
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
15
17
|
|
|
16
18
|
function memTable(rows: any[], key: string) {
|
|
17
19
|
return {
|
|
@@ -49,6 +51,31 @@ function withGithubOff(run: () => Promise<void>): Promise<void> {
|
|
|
49
51
|
});
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
test("isPrSettled reads the derived tracking view — an out-of-band-abandoned PR (base row still converging) is settled", async () => {
|
|
55
|
+
// The base `pull_requests` row still reads `converging`, but the ADR-0065 derived tracking VIEW
|
|
56
|
+
// folds the reconciler's out-of-band terminal edge into `derived_status: "abandoned"`. Terminal-edge
|
|
57
|
+
// classification must read `derived_status`, not the stale base `status`, or a crash-window RESUME
|
|
58
|
+
// of the delivery-connector enrollment action re-runs `submitPr` against a PR that has actually
|
|
59
|
+
// already settled (and the ledger detail falsely claims it enrolled).
|
|
60
|
+
const PR_KEY = "owner/repo#7";
|
|
61
|
+
const view = trackingTargetFor("pull_requests").view;
|
|
62
|
+
const base = { pr_key: PR_KEY, status: "converging" };
|
|
63
|
+
function make(derived: string) {
|
|
64
|
+
return {
|
|
65
|
+
table(name: string) {
|
|
66
|
+
if (name === "pull_requests") return { get: async (k: string) => (k === PR_KEY ? { ...base } : null) };
|
|
67
|
+
if (name === view) return { get: async (k: string) => (k === PR_KEY ? { ...base, derived_status: derived } : null) };
|
|
68
|
+
throw new Error(`unexpected table ${name}`);
|
|
69
|
+
},
|
|
70
|
+
} as any as DataLayer;
|
|
71
|
+
}
|
|
72
|
+
assertEquals(await isPrSettled(make("abandoned"), PR_KEY), true, "out-of-band-abandoned PR is settled via derived_status");
|
|
73
|
+
assertEquals(await isPrSettled(make("converging"), PR_KEY), false, "a genuinely live PR is not settled");
|
|
74
|
+
const empty = { table: () => ({ get: async () => null }) } as any as DataLayer;
|
|
75
|
+
assertEquals(await isPrSettled(empty, PR_KEY), false, "an absent PR row is not settled");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
|
|
52
79
|
test("re-submit of a cancelled PR marks stale open escalations", async () => {
|
|
53
80
|
await withGithubOff(async () => {
|
|
54
81
|
const PR_KEY = "owner/repo#42";
|
package/app/service.ts
CHANGED
|
@@ -482,6 +482,21 @@ export async function worldRestoreSha(data: DataLayer, prKey: string): Promise<s
|
|
|
482
482
|
return lastPushedSha(data, prKey);
|
|
483
483
|
}
|
|
484
484
|
|
|
485
|
+
/** Whether the `pull_requests` row for `prKey` already exists AND is in a TERMINAL state
|
|
486
|
+
* (`converged`/`merged`/`abandoned`). Reads the ADR-0065 derived tracking VIEW's `derived_status`
|
|
487
|
+
* (via `prsTracking`), NOT the base `status`, so an out-of-band-terminated PR — whose base row is
|
|
488
|
+
* still `converging` but whose reconciled edge is `abandoned` — is correctly seen as settled. The
|
|
489
|
+
* delivery-connector's converge-enrollment action guards on this so a crash-window RESUME (a
|
|
490
|
+
* `claimed`-but-not-`delivered` ledger row whose first attempt already enrolled the PR and let it
|
|
491
|
+
* settle) never re-runs `submitPr` against a settled PR — `submitPr` deliberately RE-OPENS a terminal
|
|
492
|
+
* row, so an unconditional re-perform would flip the PR back to `converging`, regressing a settled PR.
|
|
493
|
+
* Reuses the canonical `TERMINAL_STATUSES` so the terminal-safety check can't drift from the one the
|
|
494
|
+
* loop/incident logic uses. */
|
|
495
|
+
export async function isPrSettled(data: DataLayer, prKey: string): Promise<boolean> {
|
|
496
|
+
const existing = await prsTracking(data).get(prKey);
|
|
497
|
+
return !!existing && TERMINAL_STATUSES.includes(existing.derived_status);
|
|
498
|
+
}
|
|
499
|
+
|
|
485
500
|
/** Register a PR row (if new) and start the convergence process. Idempotent on prKey. Optional
|
|
486
501
|
* `dependsOn` (explicit refs) is unioned with any `Depends-on:` line parsed from the PR body and
|
|
487
502
|
* recorded as the PR's merge-stage dependency set. */
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
-- Surface delivery-graph runs in the Lineage read model (issue #498: "surface dynamic delivery
|
|
2
|
+
-- graphs in the Lineage tab as a fan-in parent thread").
|
|
3
|
+
--
|
|
4
|
+
-- 064_lineage_thread_view.sql created `lineage_thread_view`, deriving a thread's view-expressible
|
|
5
|
+
-- identity columns (`kind`, `title`, `issue_url`) from the `plans` / `feature_runs` origin joins and
|
|
6
|
+
-- passing the procedural frontier columns through from `lineage_threads`. It matched a root against
|
|
7
|
+
-- exactly two origin tables (else a self-rooted `'pr'`), so a delivery-graph run — a SEPARATE
|
|
8
|
+
-- aggregate (`delivery_graph_runs`, keyed by `run_key`) — was structurally invisible: its thread fell
|
|
9
|
+
-- through to `'pr'` with a NULL title.
|
|
10
|
+
--
|
|
11
|
+
-- `collectThreads` (app/lineage.ts) now enumerates `delivery_graph_runs` as a fan-in parent thread
|
|
12
|
+
-- keyed on `run_key`, so `pollLineage` writes a `lineage_threads` row for each run. Extend the view
|
|
13
|
+
-- with a third origin arm so it derives that row's identity from the run:
|
|
14
|
+
-- • `kind` — 'delivery' when the root matches a `delivery_graph_runs.run_key` (after the
|
|
15
|
+
-- epic/feature arms, mirroring the precedence in `collectThreads` / `deriveLineage`).
|
|
16
|
+
-- • `title` — the run's `title` (its authored delivery-graph title).
|
|
17
|
+
-- • `issue_url` — NULL: a delivery-graph run is keyed by `run_key`/`digest`, not a GitHub issue,
|
|
18
|
+
-- exactly as `deriveLineage` sets it (only feature/epic threads root on an issue).
|
|
19
|
+
-- The procedural frontier columns (`stage`/`stage_label`/`process_key`/`pr_keys`/`pr_count`/`active`)
|
|
20
|
+
-- still pass through from `lineage_threads`, so a delivery thread's frontier reflects the run's
|
|
21
|
+
-- derived phase that `pollLineage` wrote.
|
|
22
|
+
--
|
|
23
|
+
-- A VIEW cannot be `ALTER`ed, so DROP the old definition and CREATE the extended one. This is a NEW
|
|
24
|
+
-- forward-only migration — 064 stays immutable. The view remains a plain `CREATE VIEW <name> AS
|
|
25
|
+
-- SELECT … FROM …` (no CTE, no select-list subquery, every column aliased) so the static
|
|
26
|
+
-- pages↔schema contract guard can still introspect its output columns; the added CASE arm and LEFT
|
|
27
|
+
-- JOIN keep the SAME output column set, so the repointed Lineage page renders identically.
|
|
28
|
+
--
|
|
29
|
+
-- Forward-only, additive: no schema change to any base table, no DROP of `lineage_threads`. The
|
|
30
|
+
-- runner wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
31
|
+
|
|
32
|
+
DROP VIEW IF EXISTS lineage_thread_view;
|
|
33
|
+
|
|
34
|
+
CREATE VIEW lineage_thread_view AS
|
|
35
|
+
SELECT
|
|
36
|
+
lt.root_request_key AS root_request_key,
|
|
37
|
+
CASE
|
|
38
|
+
WHEN pl.plan_key IS NOT NULL THEN 'epic'
|
|
39
|
+
WHEN fr.feature_key IS NOT NULL THEN 'feature'
|
|
40
|
+
WHEN dg.run_key IS NOT NULL THEN 'delivery'
|
|
41
|
+
ELSE 'pr'
|
|
42
|
+
END AS kind,
|
|
43
|
+
CASE
|
|
44
|
+
WHEN pl.plan_key IS NOT NULL THEN pl.title
|
|
45
|
+
WHEN fr.feature_key IS NOT NULL THEN fr.title
|
|
46
|
+
WHEN dg.run_key IS NOT NULL THEN dg.title
|
|
47
|
+
ELSE lt.title
|
|
48
|
+
END AS title,
|
|
49
|
+
CASE
|
|
50
|
+
WHEN pl.plan_key IS NOT NULL THEN pl.issue_url
|
|
51
|
+
WHEN fr.feature_key IS NOT NULL THEN fr.issue_url
|
|
52
|
+
ELSE NULL
|
|
53
|
+
END AS issue_url,
|
|
54
|
+
lt.stage AS stage,
|
|
55
|
+
lt.stage_label AS stage_label,
|
|
56
|
+
lt.process_key AS process_key,
|
|
57
|
+
lt.pr_keys AS pr_keys,
|
|
58
|
+
lt.pr_count AS pr_count,
|
|
59
|
+
lt.active AS active,
|
|
60
|
+
lt.created_at AS created_at,
|
|
61
|
+
lt.updated_at AS updated_at
|
|
62
|
+
FROM lineage_threads lt
|
|
63
|
+
LEFT JOIN plans pl ON pl.plan_key = lt.root_request_key
|
|
64
|
+
LEFT JOIN feature_runs fr ON fr.feature_key = lt.root_request_key
|
|
65
|
+
LEFT JOIN delivery_graph_runs dg ON dg.run_key = lt.root_request_key;
|
|
@@ -99,6 +99,24 @@ The closed set (extensible only by a deliberate ADR/PR, never by graph authors):
|
|
|
99
99
|
- **`human`** — a scheduled user task + form (§4).
|
|
100
100
|
- **`connector`** — an automated, side-effecting outbound action (the connector I/O surface).
|
|
101
101
|
|
|
102
|
+
> **Amendment (issue #500): the connector's first REAL target landed — `converge` / `converge-merge`.**
|
|
103
|
+
> The connector I/O surface shipped in slice S4 with a deliberately forward-declared STUB action
|
|
104
|
+
> (`performConnectorAction`), the real target dispatch deferred to a later slice. That slice is this:
|
|
105
|
+
> a `connector` node whose `target` is **`converge-merge`** (or **`converge`** for converge-only)
|
|
106
|
+
> enrolls its `payload.pr` into the app's shared convergence (+ merge) loop via `submitPr` — the SAME
|
|
107
|
+
> seam the feature cell reuses (`workers/converge-feature`), no duplicated machinery. Enrollment is
|
|
108
|
+
> defined in the worker (it has `app.data`/`app.engine`) but **injected into `dispatchConnector` as the
|
|
109
|
+
> connector's action**, so the existing at-most-once ledger fence wraps the enrollment itself: it fires
|
|
110
|
+
> only on the claim winner (or a resumed crashed claim), and a `deduped` redelivery — a restart / lost
|
|
111
|
+
> ack / graph resume that lands AFTER the PR settled — never re-runs it. That matters because `submitPr`
|
|
112
|
+
> deliberately RE-OPENS a terminal PR; an unfenced re-call would flip a `merged`/`converged`/`abandoned`
|
|
113
|
+
> PR back to `converging`. `submitPr`'s own `prKey` idempotency additionally makes a resumed re-perform
|
|
114
|
+
> double-safe on a still-live row. This retires the manual `land-*` human gate whose only job was "go run convergence
|
|
115
|
+
> yourself" — the canonical shape is now `agent (opens PR) → connector[converge-merge] →
|
|
116
|
+
> wait[pr, merged]` with no human node. The payload is `{ pr, convergeOnly?, dependsOn? }`; the MVP
|
|
117
|
+
> sources `pr` as a literal (auto-emitting it from the `agent` node as a typed `pr` fact is a deferred
|
|
118
|
+
> follow-up). Other connector targets remain the forward-declared stub.
|
|
119
|
+
|
|
102
120
|
Crucially, **execution stays engine-native**: each node kind is a real, already-deployed
|
|
103
121
|
sub-process / call activity (`readiness-gate`, a user task, the implementation task, a connector
|
|
104
122
|
invocation). The graph layer owns **scheduling** (which nodes' edges are satisfied → dispatch), not a
|
package/docs/agent-guide.md
CHANGED
|
@@ -435,7 +435,7 @@ layer schedules, it does not re-implement execution):
|
|
|
435
435
|
| `agent` | `agent: { jobType, prompt? }` | a worker runs an agent job type (the fan-out body). **Side-effecting.** | yes |
|
|
436
436
|
| `wait` | `wait: <ReadinessProbe>` | a durable, bounded readiness probe — kind ∈ `http`, `command`, `npm`, `github-check`, `capability`, `pr`. Read-only. | yes (binds observed facts) |
|
|
437
437
|
| `human` | `human?: { formKey?, prompt? }` | a scheduled user task + form (the Tasks inbox, §3). Blocks dependents, SLA-bounded, answerable by a human **or** an agent. | yes |
|
|
438
|
-
| `connector` | `connector: { target, dedupeKey?, payload? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe).
|
|
438
|
+
| `connector` | `connector: { target, dedupeKey?, payload? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe). Two **real targets** ship today — **`converge`** and **`converge-merge`** (§9.4); other targets are a forward-declared stub. | yes |
|
|
439
439
|
|
|
440
440
|
A **`wait` node's `wait` is a `ReadinessProbe` verbatim** (the same shape feature-run
|
|
441
441
|
intake uses): `{ kind, target, onTimeout?, match?, poll? }`. The **`pr` kind** watches an
|
|
@@ -559,3 +559,49 @@ To swap the manual PR-#303 path for a **capability** edge instead of a raw `pr`
|
|
|
559
559
|
the consumer a `wait` node with `kind: "capability"` (resolving *which published
|
|
560
560
|
`pkg@version` first carries the change*) fed by the same `manual-publish.publishedVersion`
|
|
561
561
|
fact — the fact-edge syntax is identical.
|
|
562
|
+
|
|
563
|
+
### 9.4 Connector targets — drive a PR to convergence + merge (`converge` / `converge-merge`)
|
|
564
|
+
|
|
565
|
+
A `connector` node with **`target: "converge-merge"`** (or **`"converge"`**) enrolls an
|
|
566
|
+
agent-opened PR into the app's **shared convergence loop** — the *same* enrollment §1 (a
|
|
567
|
+
standalone submit) and a feature run use (`submitPr`), no duplicated machinery. This replaces
|
|
568
|
+
the old habit of bridging an `agent`-opened PR to review with a **human `land-*` gate** whose
|
|
569
|
+
only job was "go run convergence yourself".
|
|
570
|
+
|
|
571
|
+
- **`converge-merge`** — drive review convergence **and then the merge loop** (the PR merges
|
|
572
|
+
once converged + green). Equivalent to a submit with `convergeOnly: false`.
|
|
573
|
+
- **`converge`** — **converge-only**: drive review convergence and stop at `converged`, never
|
|
574
|
+
handing off to the merge loop (equivalent to `convergeOnly: true`).
|
|
575
|
+
|
|
576
|
+
**Payload:** `{ pr: "owner/repo#123", convergeOnly?: boolean, dependsOn?: string[] }`. `pr` is
|
|
577
|
+
required (a literal `owner/repo#N`, identical to how a `wait: pr` node targets a known PR).
|
|
578
|
+
`convergeOnly` defaults from the target and may be overridden per-node; `dependsOn` is unioned
|
|
579
|
+
into the PR's merge-stage dependency set. The enrollment is idempotent (the connector's
|
|
580
|
+
at-least-once dedupe fence **plus** `submitPr`'s own `prKey` idempotency), so a graph resume /
|
|
581
|
+
redelivery never double-enrolls.
|
|
582
|
+
|
|
583
|
+
**Canonical shape** — the agent opens the PR, the connector enrolls it, and a `wait[pr, merged]`
|
|
584
|
+
gate binds `mergedSha` when it lands, with **no human node**:
|
|
585
|
+
|
|
586
|
+
```json
|
|
587
|
+
{
|
|
588
|
+
"name": "open → converge+merge → wait merged",
|
|
589
|
+
"nodes": [
|
|
590
|
+
{ "id": "open", "kind": "agent",
|
|
591
|
+
"agent": { "jobType": "senior:feature", "prompt": "Implement the change in acme/repo and open a PR." } },
|
|
592
|
+
{ "id": "land", "kind": "connector",
|
|
593
|
+
"connector": { "target": "converge-merge", "payload": { "pr": "acme/repo#123" } } },
|
|
594
|
+
{ "id": "merged", "kind": "wait",
|
|
595
|
+
"wait": { "kind": "pr", "target": "acme/repo#123", "match": { "prState": "merged" }, "onTimeout": "escalate" } }
|
|
596
|
+
],
|
|
597
|
+
"edges": [
|
|
598
|
+
{ "from": "open", "to": "land" },
|
|
599
|
+
{ "from": "land", "to": "merged" }
|
|
600
|
+
]
|
|
601
|
+
}
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
> **Follow-up (not shipped):** the MVP sources the connector's `pr` as a **literal**. Auto-emitting
|
|
605
|
+
> the opened PR from the `agent` node as a typed `pr` fact (so the connector/`wait` bind it instead of
|
|
606
|
+
> a literal) is a later slice — not required for the graph above.
|
|
607
|
+
|
package/openapi.yaml
CHANGED
|
@@ -148,10 +148,10 @@ components:
|
|
|
148
148
|
properties:
|
|
149
149
|
rootRequestKey:
|
|
150
150
|
type: string
|
|
151
|
-
description: The origin issue key (feature_key/plan_key),
|
|
151
|
+
description: The origin issue key (feature_key/plan_key), a self-rooted pr_key, or a delivery-graph run_key.
|
|
152
152
|
kind:
|
|
153
153
|
type: string
|
|
154
|
-
enum: [feature, epic, pr]
|
|
154
|
+
enum: [feature, epic, pr, delivery]
|
|
155
155
|
title:
|
|
156
156
|
type: string
|
|
157
157
|
nullable: true
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.133.0",
|
|
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/lineage.page.json
CHANGED
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"type": "text",
|
|
71
71
|
"id": "subtitle",
|
|
72
72
|
"props": {
|
|
73
|
-
"text": "One narrative per request, not a card-swap. Each row is a single arc of your intent \u2014 request \u2192 implementing \u2192 PR opened \u2192 converging (round n) \u2192 merged/converged/abandoned \u2014 stitched from the feature/epic that started it, the PR(s) it produced, and their convergence + merge outcome. The Stage column is the active frontier; drill into a row for its member PR(s) and rounds. Epics fan out to N PR sub-threads. A human/webhook PR with no originating request is shown as its own root.",
|
|
73
|
+
"text": "One narrative per request, not a card-swap. Each row is a single arc of your intent \u2014 request \u2192 implementing \u2192 PR opened \u2192 converging (round n) \u2192 merged/converged/abandoned \u2014 stitched from the feature/epic that started it, the PR(s) it produced, and their convergence + merge outcome. The Stage column is the active frontier; drill into a row for its member PR(s) and rounds. Epics fan out to N PR sub-threads. A delivery-graph run is a fan-in parent thread: the heterogeneous downstream PR convergences it spawns (across different repos/issues) nest under the run, whose frontier reflects its derived phase. A human/webhook PR with no originating request is shown as its own root.",
|
|
74
74
|
"variant": "sub"
|
|
75
75
|
}
|
|
76
76
|
},
|