@nanobpm/nano-workforce 0.200.9 → 0.201.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 +16 -0
- package/SPEC.md +188 -0
- package/app/agentCompletion.test.ts +52 -0
- package/app/agentCompletion.ts +20 -5
- package/app/contracts.ts +9 -0
- package/app/deliveryGraph.test.ts +261 -0
- package/app/deliveryGraph.ts +334 -1
- package/app/deliveryGraphCompiler.test.ts +1698 -39
- package/app/deliveryGraphCompiler.ts +1047 -149
- package/app/deliveryGraphDeploy.test.ts +472 -3
- package/app/deliveryGraphRun.ts +2 -2
- package/app/deliveryHuman.test.ts +59 -5
- package/app/deliveryHuman.ts +115 -3
- package/app/deliveryRunner.test.ts +3 -3
- package/app/deliveryRunner.ts +14 -5
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/redactText.ts +88 -0
- package/app/roundChurn.test.ts +388 -0
- package/app/roundChurn.ts +265 -0
- package/app/roundProgress.test.ts +224 -1
- package/app/service.test.ts +7 -0
- package/app/service.ts +28 -2
- package/db/migrations/117_pull_requests_churn_escalated_round.sql +12 -0
- package/db/migrations/118_pull_requests_read_model_churn_escalated_round.sql +55 -0
- package/docs/agent-guide.md +10 -7
- package/e2e/delivery-graph-dispatch.e2e.ts +2 -1
- package/e2e/delivery-graph-node-scope.e2e.ts +358 -0
- package/e2e/delivery-graph.e2e.ts +580 -7
- package/package.json +1 -1
- package/resources/forms/delivery-escalation.form +41 -0
- package/resources/forms/delivery-human-generic.form +4 -3
- package/workers/progress-check/worker.ts +98 -1
- package/workers/readiness-probe/worker.test.ts +72 -1
- package/workers/readiness-probe/worker.ts +39 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
-- Re-create the `pull_requests_read_model` VIEW so it re-exports the base column added by
|
|
2
|
+
-- 117_pull_requests_churn_escalated_round.sql (`churn_escalated_round`). The read-model VIEW must
|
|
3
|
+
-- pass through EVERY base `pull_requests` column (the static pages↔schema contract guard +
|
|
4
|
+
-- app/pullRequestReadModel.test.ts DRIFT GUARD assert it), so adding a base column obliges a fresh
|
|
5
|
+
-- VIEW definition — 094/104 are immutable and cannot be edited in place.
|
|
6
|
+
--
|
|
7
|
+
-- Every DERIVED column below is emitted VERBATIM from the ONE declaration in
|
|
8
|
+
-- app/pullRequestReadModel.ts (`pullRequestReadModel.sqlSelectFor(col, { baseAlias: "pr" })`) — the
|
|
9
|
+
-- same closed-DSL AST that drives the runtime TS via `fnFor`, so the two lowerings cannot diverge.
|
|
10
|
+
-- This file is a mechanical re-emission of 104 with the one new base pass-through added; the
|
|
11
|
+
-- derived `list_bucket`/`ack_open` expressions are unchanged. The drift guard now points at THIS
|
|
12
|
+
-- migration (the latest VIEW definition).
|
|
13
|
+
--
|
|
14
|
+
-- Forward-only VIEW definition (DROP then CREATE), sourced off the managed `pull_requests__tracking`
|
|
15
|
+
-- re-export of the base table so a terminated PR classifies on ENGINE TRUTH. SQLite does not validate
|
|
16
|
+
-- a view body at CREATE time. The runner wraps each file in its own transaction, so this file must
|
|
17
|
+
-- NOT contain BEGIN/COMMIT. Numbered after 117.
|
|
18
|
+
|
|
19
|
+
DROP VIEW IF EXISTS pull_requests_read_model;
|
|
20
|
+
|
|
21
|
+
CREATE VIEW pull_requests_read_model AS
|
|
22
|
+
SELECT
|
|
23
|
+
pr.pr_key AS pr_key,
|
|
24
|
+
pr.repo AS repo,
|
|
25
|
+
pr.number AS number,
|
|
26
|
+
pr.url AS url,
|
|
27
|
+
pr.title AS title,
|
|
28
|
+
COALESCE(pr.derived_status, pr.status) AS status,
|
|
29
|
+
pr.current_round AS current_round,
|
|
30
|
+
pr.process_key AS process_key,
|
|
31
|
+
pr.waiting_since AS waiting_since,
|
|
32
|
+
pr.last_review_id AS last_review_id,
|
|
33
|
+
pr.outcome AS outcome,
|
|
34
|
+
pr.created_at AS created_at,
|
|
35
|
+
pr.updated_at AS updated_at,
|
|
36
|
+
pr.converged_at AS converged_at,
|
|
37
|
+
pr.merged_at AS merged_at,
|
|
38
|
+
pr.active_worker AS active_worker,
|
|
39
|
+
pr.lease_until AS lease_until,
|
|
40
|
+
pr.last_nudge_at AS last_nudge_at,
|
|
41
|
+
pr.fresh_head_run_head AS fresh_head_run_head,
|
|
42
|
+
pr.abandon_token AS abandon_token,
|
|
43
|
+
pr.incident_key AS incident_key,
|
|
44
|
+
pr.incident_message AS incident_message,
|
|
45
|
+
pr.last_round_head AS last_round_head,
|
|
46
|
+
pr.last_progress_job_key AS last_progress_job_key,
|
|
47
|
+
pr.last_progress_result AS last_progress_result,
|
|
48
|
+
pr.last_progress_agent_watermark AS last_progress_agent_watermark,
|
|
49
|
+
pr.churn_escalated_round AS churn_escalated_round,
|
|
50
|
+
pr.root_request_key AS root_request_key,
|
|
51
|
+
pr.epic_phase_label AS epic_phase_label,
|
|
52
|
+
pr.acknowledged_at AS acknowledged_at,
|
|
53
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
|
|
54
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND (NOT COALESCE(COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
|
|
55
|
+
FROM pull_requests__tracking pr;
|
package/docs/agent-guide.md
CHANGED
|
@@ -730,12 +730,15 @@ declared facts, which downstream edges bind.
|
|
|
730
730
|
> it — extend the gate's `poll.timeoutMs` and re-dispatch, or abandon the run. (Same for
|
|
731
731
|
> `onTimeout: "continue"`, which proceeds past the gate as not-ready with **no** human stop
|
|
732
732
|
> at all.)
|
|
733
|
+
>
|
|
734
|
+
> **Exception (#872):** a `wait` that declares `emits` **validates** its resume value — an
|
|
735
|
+
> omitted/invalid value **re-parks** the task rather than releasing not-ready.
|
|
733
736
|
|
|
734
737
|
> **Why the split?** Making the compile door the end of the agent surface closes a
|
|
735
|
-
> self-approval hole: the old flow handed the same caller
|
|
736
|
-
>
|
|
737
|
-
>
|
|
738
|
-
>
|
|
738
|
+
> self-approval hole: the old flow handed the same caller an approval token to re-submit with,
|
|
739
|
+
> so any holder of the API credential approved its own graph. With no dispatch affordance on the
|
|
740
|
+
> agent surface (capability by absence), the human in the cockpit is the only actor who can
|
|
741
|
+
> launch side effects.
|
|
739
742
|
|
|
740
743
|
### 9.3 Worked example — the cross-repo human-in-the-loop release
|
|
741
744
|
|
|
@@ -921,9 +924,9 @@ Semantics:
|
|
|
921
924
|
- **Set `poll.timeoutMs` to a realistic budget.** An epic reaching "fully merged" is a
|
|
922
925
|
multi-day, human-paced event, so the example gives it `poll: { everyMs: 300000, timeoutMs:
|
|
923
926
|
259200000 }` (re-probe every 5 minutes, budget 3 days). **Omitting `poll` inherits the
|
|
924
|
-
30-minute default** (§9.1) — the gate would escalate long before the epic lands
|
|
925
|
-
|
|
926
|
-
|
|
927
|
+
30-minute default** (§9.1) — the gate would escalate long before the epic lands. Because this
|
|
928
|
+
gate binds `prCount`, that escalation is **validated** (§9.2): it **re-parks** unless the
|
|
929
|
+
operator supplies a valid `prCount`, rather than silently releasing `start-b` as not-ready.
|
|
927
930
|
- On a fully-merged match it binds **`prCount`** (how many slice PRs the epic landed) as an
|
|
928
931
|
output fact, so a downstream node can consume it (parity with the `pr` kind's `mergedSha`).
|
|
929
932
|
- **It also gates a single-PR *feature run*, not just a plan-fanout epic.** The gate resolves the
|
|
@@ -21,6 +21,7 @@ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
|
21
21
|
import { deliveryGraphProposals } from "../app/deliveryGraphProposals.ts";
|
|
22
22
|
import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
|
|
23
23
|
import { pollDeliveryGraphPhase } from "../app/service.ts";
|
|
24
|
+
import { isDeliveryEscalationTwin } from "../app/deliveryHuman.ts";
|
|
24
25
|
import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
|
|
25
26
|
|
|
26
27
|
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
@@ -129,7 +130,7 @@ describe("delivery-graph dispatch — agent compiles→stages, operator dispatch
|
|
|
129
130
|
|
|
130
131
|
// ── Completion: complete the human stop → the instance ends → the poller reconciles to done ───
|
|
131
132
|
const open = await app.engine.searchUserTasks({ state: "CREATED" });
|
|
132
|
-
const human = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && !t.elementId
|
|
133
|
+
const human = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && !isDeliveryEscalationTwin(t.elementId));
|
|
133
134
|
assert.ok(human, `a human user task is open, got ${JSON.stringify(open.map((t) => t.elementId))}`);
|
|
134
135
|
await app.engine.completeUserTask(human.userTaskKey, { humanOutcome: "completed" });
|
|
135
136
|
await app.settle();
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// End-to-end proof (WASM engine) of the delivery-graph node-scope hardening, driven by the failure on
|
|
2
|
+
// live instance 171774: three escalations whose prompts carried no usable information.
|
|
3
|
+
//
|
|
4
|
+
// • NODE-LOCAL REPORT: parallel agent nodes keep their result (`status`/`summary`/`pr`/…) in their own
|
|
5
|
+
// subProcess scope. A blocked node's contract escalation shows ITS OWN report (summary, question,
|
|
6
|
+
// transcript) — not whichever sibling finished last — and the shared root holds no `pr`/`status`
|
|
7
|
+
// a sibling's `<el>_pr` output could bind (171774: a timed-out node would have bound a sibling's PR).
|
|
8
|
+
// • RETRY-NODE: completing a service-node escalation with `decision: "retry"` re-runs the node's job,
|
|
9
|
+
// passing the operator's `note` to the agent as guidance; the previous attempt's stale `blocked`
|
|
10
|
+
// status is cleared, so a rerun that succeeds without reporting a status passes the contract gate.
|
|
11
|
+
// • PREFLIGHT: a node whose runner-seeded `nodeInputs` config is missing (lost root variables) raises
|
|
12
|
+
// an incident naming the cause instead of running blind; restoring the variables and resolving the
|
|
13
|
+
// incident lets the node proceed.
|
|
14
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join, resolve } from "node:path";
|
|
17
|
+
import { after, describe, test } from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
20
|
+
import { runDeliveryGraph } from "../app/deliveryRunner.ts";
|
|
21
|
+
import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
|
|
22
|
+
import { pollUserTasks } from "../app/service.ts";
|
|
23
|
+
import { userTasks } from "../app/userTasks.ts";
|
|
24
|
+
import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
|
|
25
|
+
|
|
26
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
27
|
+
const GITHUB_ENV: Record<string, string> = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "" };
|
|
28
|
+
|
|
29
|
+
const PR_A = "https://github.com/acme/repo/pull/1";
|
|
30
|
+
const PR_B = "https://github.com/acme/repo/pull/2";
|
|
31
|
+
|
|
32
|
+
/** Two independent producer→consumer chains (`a → ca`, `b → cb`): each agent's `pr` is a REQUIRED data
|
|
33
|
+
* dependency of its connector, so each agent node carries a producer-contract gate. */
|
|
34
|
+
const TWO_CHAINS: DeliveryGraph = {
|
|
35
|
+
name: "node-scope e2e",
|
|
36
|
+
nodes: [
|
|
37
|
+
{ id: "a", kind: "agent", agent: { jobType: "senior:a" }, emits: [{ name: "pr", type: "pr" }] },
|
|
38
|
+
{ id: "b", kind: "agent", agent: { jobType: "senior:b" }, emits: [{ name: "pr", type: "pr" }] },
|
|
39
|
+
{ id: "ca", kind: "connector", connector: { target: "slack", dedupeKey: "ca-1", payload: { pr: "a.pr" } } },
|
|
40
|
+
{ id: "cb", kind: "connector", connector: { target: "slack", dedupeKey: "cb-1", payload: { pr: "b.pr" } } },
|
|
41
|
+
],
|
|
42
|
+
edges: [
|
|
43
|
+
{ from: "a.pr", to: "ca" },
|
|
44
|
+
{ from: "b.pr", to: "cb" },
|
|
45
|
+
],
|
|
46
|
+
} as DeliveryGraph;
|
|
47
|
+
|
|
48
|
+
describe("delivery-graph node scope — node-local agent reports, retry-node, preflight", () => {
|
|
49
|
+
const dirs: string[] = [];
|
|
50
|
+
const apps: TestApp[] = [];
|
|
51
|
+
after(async () => {
|
|
52
|
+
for (const app of apps) await app.stop?.();
|
|
53
|
+
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
async function boot(): Promise<TestApp> {
|
|
56
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-node-scope-e2e-"));
|
|
57
|
+
dirs.push(dir);
|
|
58
|
+
const app = await bootTestApp(APP_ROOT, { env: { ...GITHUB_ENV, NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
|
|
59
|
+
apps.push(app);
|
|
60
|
+
return app;
|
|
61
|
+
}
|
|
62
|
+
/** Observe each connector's late-bound facts, keyed by its dedupe key. */
|
|
63
|
+
async function observeConnectors(app: TestApp): Promise<Map<string, unknown>> {
|
|
64
|
+
const seen = new Map<string, unknown>();
|
|
65
|
+
await app.engine.registerWorker(
|
|
66
|
+
"pr.delivery-connector",
|
|
67
|
+
async (job) => {
|
|
68
|
+
const vars = job.variables as Record<string, unknown>;
|
|
69
|
+
seen.set(String(vars.dedupeKey), vars.boundFacts);
|
|
70
|
+
return {};
|
|
71
|
+
},
|
|
72
|
+
{ fetchVariables: ["boundFacts", "dedupeKey"] },
|
|
73
|
+
);
|
|
74
|
+
return seen;
|
|
75
|
+
}
|
|
76
|
+
async function rootVars(app: TestApp, pik: string): Promise<Map<string, unknown>> {
|
|
77
|
+
const vars = await app.engine.searchVariables({ processInstanceKey: pik } as never);
|
|
78
|
+
return new Map(vars.filter((v) => String(v.scopeKey) === pik).map((v) => [v.name, JSON.parse(v.value) as unknown]));
|
|
79
|
+
}
|
|
80
|
+
async function openTask(app: TestApp, suffix: string) {
|
|
81
|
+
const open = await app.engine.searchUserTasks({ state: "CREATED" });
|
|
82
|
+
return open.find((t) => t.elementId?.endsWith(suffix));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test("a blocked agent's contract escalation carries ITS OWN report; siblings and the root never mix results", async () => {
|
|
86
|
+
const app = await boot();
|
|
87
|
+
const connectors = await observeConnectors(app);
|
|
88
|
+
await app.engine.registerWorker("senior:a", async () => ({ status: "done", pr: PR_A, summary: "opened PR 1" }));
|
|
89
|
+
await app.engine.registerWorker("senior:b", async () => ({
|
|
90
|
+
status: "blocked",
|
|
91
|
+
summary: "workspace clone not provisioned: repository unset",
|
|
92
|
+
question: "Which repository should I clone?",
|
|
93
|
+
transcriptUrl: "http://localhost:3000/app/api/agentic/transcripts/w/42",
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
const run = await runDeliveryGraph(app.engine, TWO_CHAINS, { repoless: true });
|
|
97
|
+
assert.ok(run.ok, JSON.stringify(run));
|
|
98
|
+
const pik = run.handle.processInstanceKey;
|
|
99
|
+
await app.settle();
|
|
100
|
+
|
|
101
|
+
// `a` met its contract and handed ITS pr to `ca`; `b` parked on its own contract escalation.
|
|
102
|
+
assert.deepEqual(connectors.get("ca-1"), [{ from: "a", name: "pr", value: PR_A }]);
|
|
103
|
+
assert.equal(connectors.has("cb-1"), false, "the blocked producer's consumer did not fire");
|
|
104
|
+
const esc = await openTask(app, "__contract");
|
|
105
|
+
assert.ok(esc, "b parked on its producer-contract escalation");
|
|
106
|
+
// What the OPERATOR sees: the Tasks inbox "Decision context" (`user_tasks.question`) projected by
|
|
107
|
+
// the poller for a running delivery run — previously the static "A scheduled delivery-graph step is
|
|
108
|
+
// waiting to be completed." for every agent escalation twin.
|
|
109
|
+
const ts = new Date().toISOString();
|
|
110
|
+
await deliveryGraphRuns(app.db).insert({ run_key: "r-node-scope", digest: "d", status: "running", process_key: pik, created_at: ts, updated_at: ts } as never);
|
|
111
|
+
await pollUserTasks(app.db, app.engine);
|
|
112
|
+
const row = await userTasks(app.db).get(String(esc.userTaskKey));
|
|
113
|
+
const prompt = String(row?.question ?? "");
|
|
114
|
+
assert.match(prompt, /Reported status=blocked/);
|
|
115
|
+
assert.match(prompt, /Agent report: workspace clone not provisioned: repository unset/);
|
|
116
|
+
assert.match(prompt, /Agent question: Which repository should I clone\?/);
|
|
117
|
+
assert.match(prompt, /Transcript: http:\/\/localhost:3000\/app\/api\/agentic\/transcripts\/w\/42/);
|
|
118
|
+
assert.match(prompt, /Retry this step/, "the prompt names how to resolve it");
|
|
119
|
+
assert.doesNotMatch(prompt, /opened PR 1/, "a sibling's report never leaks into this node's escalation");
|
|
120
|
+
|
|
121
|
+
// The shared root holds no agent result a sibling could read back.
|
|
122
|
+
const root = await rootVars(app, pik);
|
|
123
|
+
for (const name of ["status", "summary", "question", "pr", "value", "decision"]) {
|
|
124
|
+
assert.equal(root.has(name), false, `root must not carry the node-local '${name}'`);
|
|
125
|
+
}
|
|
126
|
+
assert.equal(root.get("n0_pr") ?? root.get(`${"n0"}_pr`), PR_A, "a's emit still publishes onward as <el>_pr");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("retry-node: decision=retry re-runs the job with the operator's note; a status-less success then passes the gate", async () => {
|
|
130
|
+
const app = await boot();
|
|
131
|
+
const connectors = await observeConnectors(app);
|
|
132
|
+
await app.engine.registerWorker("senior:a", async () => ({ status: "done", pr: PR_A }));
|
|
133
|
+
const bPrompts: unknown[] = [];
|
|
134
|
+
await app.engine.registerWorker(
|
|
135
|
+
"senior:b",
|
|
136
|
+
async (job) => {
|
|
137
|
+
bPrompts.push((job.variables as Record<string, unknown>).appendPrompt);
|
|
138
|
+
// 1st attempt: blocked. 2nd attempt: succeeds WITHOUT reporting a status (a `completed` ACP
|
|
139
|
+
// outcome derives no flat status) — the stale `blocked` must not fail the gate again.
|
|
140
|
+
return bPrompts.length === 1 ? { status: "blocked", summary: "no repo" } : { pr: PR_B };
|
|
141
|
+
},
|
|
142
|
+
{ fetchVariables: ["appendPrompt"] },
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const run = await runDeliveryGraph(app.engine, TWO_CHAINS, { repoless: true });
|
|
146
|
+
assert.ok(run.ok, JSON.stringify(run));
|
|
147
|
+
await app.settle();
|
|
148
|
+
const esc = await openTask(app, "__contract");
|
|
149
|
+
assert.ok(esc, "b parked on its contract escalation");
|
|
150
|
+
|
|
151
|
+
await app.engine.completeUserTask(esc.userTaskKey, { decision: "retry", escalationNote: "clone acme/repo" });
|
|
152
|
+
await app.settle();
|
|
153
|
+
|
|
154
|
+
assert.equal(bPrompts.length, 2, "the retry re-ran b's job");
|
|
155
|
+
assert.match(String(bPrompts[1]), /Operator guidance for this retry: clone acme\/repo/);
|
|
156
|
+
assert.equal(await openTask(app, "__contract"), undefined, "the status-less rerun passed the contract gate");
|
|
157
|
+
assert.deepEqual(connectors.get("cb-1"), [{ from: "b", name: "pr", value: PR_B }], "b's retried PR threads onward");
|
|
158
|
+
const flows = (app.snapshot().takenSequenceFlows ?? []) as { to: string }[];
|
|
159
|
+
assert.ok(flows.some((f) => f.to === "End"), "the graph completed");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("retry-node: consecutive retries never accumulate stale operator notes in the prompt", async () => {
|
|
163
|
+
// Regression (PR #863 adversarial review): the retry reset must re-derive `appendPrompt` from the
|
|
164
|
+
// runner-seeded `nodeInputs` baseline — building on the live var carried retry 1's note into retry
|
|
165
|
+
// 2's prompt, growing one stale "Operator guidance…" paragraph per consecutive retry.
|
|
166
|
+
const app = await boot();
|
|
167
|
+
await observeConnectors(app);
|
|
168
|
+
await app.engine.registerWorker("senior:a", async () => ({ status: "done", pr: PR_A }));
|
|
169
|
+
const bPrompts: unknown[] = [];
|
|
170
|
+
await app.engine.registerWorker(
|
|
171
|
+
"senior:b",
|
|
172
|
+
async (job) => {
|
|
173
|
+
bPrompts.push((job.variables as Record<string, unknown>).appendPrompt);
|
|
174
|
+
// Every attempt reports blocked until the THIRD, which succeeds — forcing TWO retries.
|
|
175
|
+
return bPrompts.length < 3 ? { status: "blocked", summary: "no repo" } : { pr: PR_B };
|
|
176
|
+
},
|
|
177
|
+
{ fetchVariables: ["appendPrompt"] },
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const run = await runDeliveryGraph(app.engine, TWO_CHAINS, { repoless: true });
|
|
181
|
+
assert.ok(run.ok, JSON.stringify(run));
|
|
182
|
+
await app.settle();
|
|
183
|
+
|
|
184
|
+
const first = await openTask(app, "__contract");
|
|
185
|
+
assert.ok(first, "b parked on its contract escalation");
|
|
186
|
+
await app.engine.completeUserTask(first.userTaskKey, { decision: "retry", escalationNote: "first note" });
|
|
187
|
+
await app.settle();
|
|
188
|
+
assert.equal(bPrompts.length, 2, "retry 1 re-ran b's job");
|
|
189
|
+
|
|
190
|
+
const second = await openTask(app, "__contract");
|
|
191
|
+
assert.ok(second, "b parked again after retry 1 reported blocked");
|
|
192
|
+
await app.engine.completeUserTask(second.userTaskKey, { decision: "retry", escalationNote: "second note" });
|
|
193
|
+
await app.settle();
|
|
194
|
+
assert.equal(bPrompts.length, 3, "retry 2 re-ran b's job");
|
|
195
|
+
|
|
196
|
+
assert.match(String(bPrompts[1]), /Operator guidance for this retry: first note/);
|
|
197
|
+
assert.match(String(bPrompts[2]), /Operator guidance for this retry: second note/, "retry 2 carries its own note");
|
|
198
|
+
assert.doesNotMatch(String(bPrompts[2]), /first note/, "retry 2's prompt does NOT accumulate retry 1's note");
|
|
199
|
+
assert.equal((String(bPrompts[2]).match(/Operator guidance for this retry:/g) ?? []).length, 1, "exactly one guidance paragraph");
|
|
200
|
+
assert.equal(await openTask(app, "__contract"), undefined, "the third attempt passed the contract gate");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("retry-node: a retry with an omitted operator note never reuses the previous attempt's worker `note` result", async () => {
|
|
204
|
+
// Regression (PR #863 Copilot "Previously missed", deliveryGraphCompiler.ts:2156): the escalation
|
|
205
|
+
// note control shared the plain name `note` with a WORKER RESULT field the built-in plan prompt
|
|
206
|
+
// returns (resources/prompts/plan.md). Declared node-local via ESCALATION_LOCAL_VARS, a worker's
|
|
207
|
+
// `note` landed in the same subProcess scope the retry reset reads for "Operator guidance…" — so
|
|
208
|
+
// completing the escalation with `{ decision: "retry" }` and NO note (the form contract permits
|
|
209
|
+
// it) fed the worker's own stale note back to the agent as if the operator had written it. The
|
|
210
|
+
// control is renamed `escalationNote` (a name no worker result contract may use), so an omitted
|
|
211
|
+
// operator note appends NOTHING.
|
|
212
|
+
const app = await boot();
|
|
213
|
+
await observeConnectors(app);
|
|
214
|
+
await app.engine.registerWorker("senior:a", async () => ({ status: "done", pr: PR_A }));
|
|
215
|
+
const bPrompts: unknown[] = [];
|
|
216
|
+
await app.engine.registerWorker(
|
|
217
|
+
"senior:b",
|
|
218
|
+
async (job) => {
|
|
219
|
+
bPrompts.push((job.variables as Record<string, unknown>).appendPrompt);
|
|
220
|
+
// 1st attempt: blocked AND returns a worker `note` (the plan.md contract). 2nd: succeeds.
|
|
221
|
+
return bPrompts.length === 1
|
|
222
|
+
? { status: "blocked", summary: "no repo", note: "worker-produced note" }
|
|
223
|
+
: { pr: PR_B };
|
|
224
|
+
},
|
|
225
|
+
{ fetchVariables: ["appendPrompt"] },
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const run = await runDeliveryGraph(app.engine, TWO_CHAINS, { repoless: true });
|
|
229
|
+
assert.ok(run.ok, JSON.stringify(run));
|
|
230
|
+
await app.settle();
|
|
231
|
+
const esc = await openTask(app, "__contract");
|
|
232
|
+
assert.ok(esc, "b parked on its contract escalation");
|
|
233
|
+
|
|
234
|
+
// Retry WITHOUT an operator note — the escalation form permits omitting it.
|
|
235
|
+
await app.engine.completeUserTask(esc.userTaskKey, { decision: "retry" });
|
|
236
|
+
await app.settle();
|
|
237
|
+
|
|
238
|
+
assert.equal(bPrompts.length, 2, "the retry re-ran b's job");
|
|
239
|
+
assert.doesNotMatch(
|
|
240
|
+
String(bPrompts[1]),
|
|
241
|
+
/Operator guidance for this retry:/,
|
|
242
|
+
"an omitted operator note appends NO guidance paragraph",
|
|
243
|
+
);
|
|
244
|
+
assert.doesNotMatch(
|
|
245
|
+
String(bPrompts[1]),
|
|
246
|
+
/worker-produced note/,
|
|
247
|
+
"the previous attempt's worker `note` result is never fed back as operator guidance",
|
|
248
|
+
);
|
|
249
|
+
assert.equal(await openTask(app, "__contract"), undefined, "the retried run passed the contract gate");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("a continue resolution (no decision) supplies the missing emit and does not loop", async () => {
|
|
253
|
+
const app = await boot();
|
|
254
|
+
const connectors = await observeConnectors(app);
|
|
255
|
+
await app.engine.registerWorker("senior:a", async () => ({ status: "done", pr: PR_A }));
|
|
256
|
+
let bRuns = 0;
|
|
257
|
+
await app.engine.registerWorker("senior:b", async () => {
|
|
258
|
+
bRuns++;
|
|
259
|
+
return { status: "blocked", summary: "no repo" };
|
|
260
|
+
});
|
|
261
|
+
const run = await runDeliveryGraph(app.engine, TWO_CHAINS, { repoless: true });
|
|
262
|
+
assert.ok(run.ok, JSON.stringify(run));
|
|
263
|
+
await app.settle();
|
|
264
|
+
const esc = await openTask(app, "__contract");
|
|
265
|
+
assert.ok(esc);
|
|
266
|
+
await app.engine.completeUserTask(esc.userTaskKey, { value: PR_B });
|
|
267
|
+
await app.settle();
|
|
268
|
+
assert.equal(bRuns, 1, "no retry without decision=retry");
|
|
269
|
+
assert.deepEqual(connectors.get("cb-1"), [{ from: "b", name: "pr", value: PR_B }], "the supplied PR resumes b");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("a boolean single-emit Continue coerces the textfield string to a real boolean (typed-resume regression)", async () => {
|
|
273
|
+
// Regression guard (PR #863 Copilot High, thread r4182488193): the escalation form captures `value`
|
|
274
|
+
// as textfield TEXT, but a `boolean` emit's downstream guard compares against a typed `= true`. The
|
|
275
|
+
// single-emit resume must coerce the string to a real boolean — a raw `"true"` string would skip the
|
|
276
|
+
// guard. Verified end-to-end against the WASM engine (the FEEL `matches` has no inline `(?i:)` flag,
|
|
277
|
+
// so the coercion lower-cases the input).
|
|
278
|
+
const app = await boot();
|
|
279
|
+
const connectors = await observeConnectors(app);
|
|
280
|
+
const graph = {
|
|
281
|
+
name: "bool-coerce",
|
|
282
|
+
nodes: [
|
|
283
|
+
{ id: "b", kind: "agent", agent: { jobType: "senior:b" }, emits: [{ name: "ok", type: "boolean" }] },
|
|
284
|
+
{ id: "cb", kind: "connector", connector: { target: "slack", dedupeKey: "cb-1", payload: { ok: "b.ok" } } },
|
|
285
|
+
],
|
|
286
|
+
edges: [{ from: "b.ok", to: "cb" }],
|
|
287
|
+
} as never;
|
|
288
|
+
await app.engine.registerWorker("senior:b", async () => ({ status: "blocked", summary: "no repo" }));
|
|
289
|
+
const run = await runDeliveryGraph(app.engine, graph, { repoless: true });
|
|
290
|
+
assert.ok(run.ok, JSON.stringify(run));
|
|
291
|
+
await app.settle();
|
|
292
|
+
const esc = await openTask(app, "__contract");
|
|
293
|
+
assert.ok(esc);
|
|
294
|
+
await app.engine.completeUserTask(esc.userTaskKey, { value: "true" });
|
|
295
|
+
await app.settle();
|
|
296
|
+
assert.deepEqual(connectors.get("cb-1"), [{ from: "b", name: "ok", value: true }], "the textfield \"true\" resumes b as a real boolean");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("preflight: a node whose nodeInputs were lost raises an incident naming the cause, and proceeds once restored", async () => {
|
|
300
|
+
const app = await boot();
|
|
301
|
+
await observeConnectors(app);
|
|
302
|
+
// `a` simulates the root-variable loss (nano-bpm#1331) while it runs: it completes with
|
|
303
|
+
// `nodeInputs: null`, which propagates to the root `nodeInputs` the runner seeded.
|
|
304
|
+
let seed: unknown;
|
|
305
|
+
await app.engine.registerWorker("senior:a", async () => ({ status: "done", pr: PR_A, nodeInputs: null }));
|
|
306
|
+
let bRuns = 0;
|
|
307
|
+
const bJobTypes: unknown[] = [];
|
|
308
|
+
await app.engine.registerWorker(
|
|
309
|
+
"senior:b",
|
|
310
|
+
async (job) => {
|
|
311
|
+
bRuns++;
|
|
312
|
+
bJobTypes.push((job.variables as Record<string, unknown>).jobType);
|
|
313
|
+
return { status: "done", pr: PR_B };
|
|
314
|
+
},
|
|
315
|
+
{ fetchVariables: ["jobType"] },
|
|
316
|
+
);
|
|
317
|
+
const graph = {
|
|
318
|
+
name: "preflight e2e",
|
|
319
|
+
nodes: [
|
|
320
|
+
{ id: "a", kind: "agent", agent: { jobType: "senior:a" }, emits: [{ name: "pr", type: "pr" }] },
|
|
321
|
+
{ id: "b", kind: "agent", agent: { jobType: "senior:b" }, emits: [{ name: "pr", type: "pr" }] },
|
|
322
|
+
],
|
|
323
|
+
edges: [{ from: "a.pr", to: "b" }],
|
|
324
|
+
} as DeliveryGraph;
|
|
325
|
+
const run = await runDeliveryGraph(app.engine, graph, { repoless: true });
|
|
326
|
+
assert.ok(run.ok, JSON.stringify(run));
|
|
327
|
+
seed = run.handle.nodeInputs;
|
|
328
|
+
const pik = run.handle.processInstanceKey;
|
|
329
|
+
await app.settle();
|
|
330
|
+
|
|
331
|
+
assert.equal(bRuns, 0, "b never ran blind");
|
|
332
|
+
const incidents = await app.engine.searchIncidents({ processInstanceKey: pik } as never);
|
|
333
|
+
const inc = incidents.find((i) => /nodeInputs\.n1 is missing/.test(String(i.errorMessage ?? "")));
|
|
334
|
+
assert.ok(inc, `an incident names the missing node inputs, got ${JSON.stringify(incidents)}`);
|
|
335
|
+
|
|
336
|
+
// Repair: restore the root variables, resolve the incident → b runs and the graph completes.
|
|
337
|
+
await app.engine.setVariables({ scopeKey: pik, variables: { nodeInputs: seed } });
|
|
338
|
+
await app.engine.resolveIncident({ incidentKey: String(inc.incidentKey) });
|
|
339
|
+
await app.settle();
|
|
340
|
+
assert.equal(bRuns, 1, "b ran once its inputs were restored");
|
|
341
|
+
// Do NOT bless leaf-only resolution as a CONFIGURED recovery (PR #863 Copilot Medium, thread
|
|
342
|
+
// r4181322055). The KNOWN LIMITATION (nano-workforce#866) is that resolving the leaf incident
|
|
343
|
+
// re-evaluates ONLY the leaf's inputs — the subProcess-level config mappings ran once at subProcess
|
|
344
|
+
// entry and are NOT re-mapped on resolve — so this leaf-only path runs b BLIND (`jobType` is null).
|
|
345
|
+
// Assert that honestly: this scenario proves the preflight incident PREVENTS the blind run up front
|
|
346
|
+
// (bRuns stayed 0 while the config was missing) and that resolution unblocks the node — NOT that
|
|
347
|
+
// leaf-resolution restores the config. The correct recovery (re-entering the sub-process so its
|
|
348
|
+
// input mappings re-evaluate) is a separate operator action the testkit cannot drive via
|
|
349
|
+
// resolveIncident; SPEC documents it as the required workaround.
|
|
350
|
+
assert.equal(
|
|
351
|
+
bJobTypes[0] ?? null,
|
|
352
|
+
null,
|
|
353
|
+
"leaf-only resolution re-runs b BLIND (the #866 known limitation) — the preflight's value is blocking the blind run BEFORE a job exists, not restoring config on resolve",
|
|
354
|
+
);
|
|
355
|
+
const flows = (app.snapshot().takenSequenceFlows ?? []) as { to: string }[];
|
|
356
|
+
assert.ok(flows.some((f) => f.to === "End"), "the graph completed");
|
|
357
|
+
});
|
|
358
|
+
});
|