@nanobpm/nano-workforce 0.209.0 → 0.210.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +11 -1
  3. package/app/behindBaseRemedy.test.ts +115 -0
  4. package/app/behindBaseRemedy.ts +95 -0
  5. package/app/convergeAdversarial.test.ts +152 -0
  6. package/app/feature.test.ts +31 -0
  7. package/app/feature.ts +12 -0
  8. package/app/github.test.ts +137 -2
  9. package/app/github.ts +137 -1
  10. package/app/mergeLoopBehaviour.test.ts +97 -0
  11. package/app/pullRequestReadModel.test.ts +1 -1
  12. package/app/service.test.ts +125 -1
  13. package/app/service.ts +70 -1
  14. package/build-info.json +4 -4
  15. package/db/migrations/134_pull_requests_custom_instructions.sql +8 -0
  16. package/db/migrations/135_pull_requests_read_model_custom_instructions.sql +52 -0
  17. package/db/migrations/136_pull_requests_custom_instructions_text.sql +11 -0
  18. package/db/migrations/137_feature_runs_custom_instructions.sql +66 -0
  19. package/db/migrations/138_pull_requests_read_model_custom_instructions_text.sql +53 -0
  20. package/docs/agent-guide.md +1 -0
  21. package/openapi.yaml +24 -0
  22. package/operations/listActivePrs.test.ts +17 -0
  23. package/operations/startAndMessage.test.ts +80 -0
  24. package/operations/startConvergenceLoop.ts +13 -1
  25. package/package.json +1 -1
  26. package/pages/home.page.json +7 -2
  27. package/resources/processes/convergence-loop.bpmn +8 -2
  28. package/resources/processes/feature.bpmn +1 -0
  29. package/resources/processes/merge-loop.bpmn +8 -3
  30. package/workers/converge-feature/worker.ts +10 -1
  31. package/workers/merge-stall-probe/worker.test.ts +119 -0
  32. package/workers/merge-stall-probe/worker.ts +69 -8
  33. package/workers/reconverge/worker.test.ts +50 -0
  34. package/workers/reconverge/worker.ts +7 -0
  35. package/workers/request-review/worker.test.ts +27 -0
  36. package/workers/request-review/worker.ts +42 -2
@@ -0,0 +1,52 @@
1
+ -- Re-create the `pull_requests_read_model` VIEW so it re-exports the new base column (the VIEW must pass
2
+ -- through EVERY base `pull_requests` column — app/pullRequestReadModel.test.ts DRIFT GUARD). A mechanical
3
+ -- re-emission of 133 with `custom_instructions` (134, issue #956) added; the derived columns are emitted
4
+ -- VERBATIM from app/pullRequestReadModel.ts and are unchanged. No BEGIN/COMMIT (the runner wraps each
5
+ -- file). Numbered after 134.
6
+
7
+ DROP VIEW IF EXISTS pull_requests_read_model;
8
+
9
+ CREATE VIEW pull_requests_read_model AS
10
+ SELECT
11
+ pr.pr_key AS pr_key,
12
+ pr.repo AS repo,
13
+ pr.number AS number,
14
+ pr.url AS url,
15
+ pr.title AS title,
16
+ COALESCE(pr.derived_status, pr.status) AS status,
17
+ pr.current_round AS current_round,
18
+ pr.process_key AS process_key,
19
+ pr.waiting_since AS waiting_since,
20
+ pr.last_review_id AS last_review_id,
21
+ pr.outcome AS outcome,
22
+ pr.created_at AS created_at,
23
+ pr.updated_at AS updated_at,
24
+ pr.converged_at AS converged_at,
25
+ pr.merged_at AS merged_at,
26
+ pr.active_worker AS active_worker,
27
+ pr.lease_until AS lease_until,
28
+ pr.last_nudge_at AS last_nudge_at,
29
+ pr.fresh_head_run_head AS fresh_head_run_head,
30
+ pr.abandon_token AS abandon_token,
31
+ pr.incident_key AS incident_key,
32
+ pr.incident_type AS incident_type,
33
+ pr.incident_since AS incident_since,
34
+ pr.incident_message AS incident_message,
35
+ pr.last_round_head AS last_round_head,
36
+ pr.last_progress_job_key AS last_progress_job_key,
37
+ pr.last_progress_result AS last_progress_result,
38
+ pr.last_progress_agent_watermark AS last_progress_agent_watermark,
39
+ pr.churn_escalated_round AS churn_escalated_round,
40
+ pr.root_request_key AS root_request_key,
41
+ pr.epic_phase_label AS epic_phase_label,
42
+ pr.acknowledged_at AS acknowledged_at,
43
+ pr.auto_resolve_escalations AS auto_resolve_escalations,
44
+ pr.termination_reason AS termination_reason,
45
+ pr.termination_process_key AS termination_process_key,
46
+ pr.retire_pending_key AS retire_pending_key,
47
+ pr.merge_requested AS merge_requested,
48
+ pr.merge_human_approval AS merge_human_approval,
49
+ pr.custom_instructions AS custom_instructions,
50
+ 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,
51
+ 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
52
+ FROM pull_requests__tracking pr;
@@ -0,0 +1,11 @@
1
+ -- Durable operator-steering TEXT for the convergence lifecycle (issue #956 review, Copilot threads
2
+ -- 4547353262 / 4547353302). Migration 134 added only the boolean `custom_instructions` PROJECTION
3
+ -- (1 ⇒ steered) so `/status` needs no engine read — but the TEXT itself lived solely on the
4
+ -- convergence instance's variables. Every path that RE-ENTERS `submitPr` without the text
5
+ -- (the merge→reconvergence `pr.reconverge` handoff, `handoffWaitingMergeToConvergence`, the
6
+ -- poller's feature-delivery re-enroll) therefore started a replacement loop that ran UNSTEERED and
7
+ -- reset the flag to 0. Persist the trimmed text on the row so any handoff can re-supply it, and so
8
+ -- `startMerge` can re-seed the merge-loop instance (whose `pr.reconverge` is one of those paths).
9
+ -- Run-scoped exactly like `custom_instructions`: the atomic reopen rewrites it to the fresh run's
10
+ -- steering. NULL on every pre-existing row; those read as "no instructions".
11
+ ALTER TABLE pull_requests ADD COLUMN custom_instructions_text TEXT;
@@ -0,0 +1,66 @@
1
+ -- Durable operator-steering TEXT on the feature run (issue #956 review, Copilot thread
2
+ -- 4547353302). `startFeature` already seeds `customInstructions` onto the feature INSTANCE's
3
+ -- variables, and the `pr.converge-feature` worker forwards it to `submitPr` — but the worker
4
+ -- publishes `converging` BEFORE enrolling, so `pollFeatureDelivery`'s re-enroll edge can win the
5
+ -- race (or recover an interrupted handoff) and call `submitPr` with NO job variables, starting a
6
+ -- loop without the instructions; the worker's later call then returns `alreadyRunning` without
7
+ -- updating the live loop. Persist the trimmed text on the run row BEFORE the instance/handoff
8
+ -- exists so the poller's enrollment path (and the worker) read the SAME durable source. The text
9
+ -- is the operator's own input (already written to the instance and prompts), so a TEXT column
10
+ -- carries no new sensitivity class. NULL when the run carries no steering.
11
+ --
12
+ -- The column is mirrored through the `delivery_units` aggregate so the `feature_runs__units` compat
13
+ -- VIEW stays row-for-row equal to `feature_runs` (ADR 0006, parity-tested in
14
+ -- app/deliveryUnit.test.ts): the Feature sync triggers (089) and VIEW (091) are re-created verbatim
15
+ -- plus `custom_instructions`, exactly as migration 125 did for the pr-link columns.
16
+
17
+ ALTER TABLE feature_runs ADD COLUMN custom_instructions TEXT;
18
+
19
+ ALTER TABLE delivery_units ADD COLUMN custom_instructions TEXT;
20
+
21
+ DROP TRIGGER IF EXISTS feature_runs__du_ai;
22
+ CREATE TRIGGER feature_runs__du_ai AFTER INSERT ON feature_runs
23
+ BEGIN
24
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, auto_resolve_escalations, custom_instructions, outcome, delivery_label, pr_linked_pr_key, pr_link_want_pr_key, pr_link_lease_at, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
25
+ VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.auto_resolve_escalations, NEW.custom_instructions, NEW.outcome, NEW.delivery_label, NEW.pr_linked_pr_key, NEW.pr_link_want_pr_key, NEW.pr_link_lease_at, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
26
+ END;
27
+
28
+ DROP TRIGGER IF EXISTS feature_runs__du_au;
29
+ CREATE TRIGGER feature_runs__du_au AFTER UPDATE ON feature_runs
30
+ BEGIN
31
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, auto_resolve_escalations, custom_instructions, outcome, delivery_label, pr_linked_pr_key, pr_link_want_pr_key, pr_link_lease_at, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
32
+ VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.auto_resolve_escalations, NEW.custom_instructions, NEW.outcome, NEW.delivery_label, NEW.pr_linked_pr_key, NEW.pr_link_want_pr_key, NEW.pr_link_lease_at, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
33
+ END;
34
+
35
+ DROP VIEW IF EXISTS feature_runs__units;
36
+ CREATE VIEW feature_runs__units AS
37
+ SELECT
38
+ du.legacy_key AS feature_key,
39
+ du.repo AS repo,
40
+ du.issue_number AS issue_number,
41
+ du.issue_url AS issue_url,
42
+ du.base_branch AS base_branch,
43
+ du.status AS status,
44
+ du.process_key AS process_key,
45
+ du.pr_key AS pr_key,
46
+ du.converge AS converge,
47
+ du.auto_merge AS auto_merge,
48
+ du.human_approval AS human_approval,
49
+ du.auto_resolve_escalations AS auto_resolve_escalations,
50
+ du.custom_instructions AS custom_instructions,
51
+ du.outcome AS outcome,
52
+ du.created_at AS created_at,
53
+ du.updated_at AS updated_at,
54
+ du.delivery_label AS delivery_label,
55
+ du.pr_linked_pr_key AS pr_linked_pr_key,
56
+ du.pr_link_want_pr_key AS pr_link_want_pr_key,
57
+ du.pr_link_lease_at AS pr_link_lease_at,
58
+ du.title AS title,
59
+ du.acknowledged_at AS acknowledged_at,
60
+ du.stage AS stage,
61
+ du.stage_state AS stage_state,
62
+ du.stage_skipped AS stage_skipped,
63
+ du.attention AS attention,
64
+ du.list_bucket AS list_bucket
65
+ FROM delivery_units du
66
+ WHERE du.kind = 'feature';
@@ -0,0 +1,53 @@
1
+ -- Re-create the `pull_requests_read_model` VIEW so it re-exports the new base column (the VIEW must pass
2
+ -- through EVERY base `pull_requests` column — app/pullRequestReadModel.test.ts DRIFT GUARD). A mechanical
3
+ -- re-emission of 135 with `custom_instructions_text` (136, issue #956 review) added; the derived columns are emitted
4
+ -- VERBATIM from app/pullRequestReadModel.ts and are unchanged. No BEGIN/COMMIT (the runner wraps each
5
+ -- file). Numbered after 137.
6
+
7
+ DROP VIEW IF EXISTS pull_requests_read_model;
8
+
9
+ CREATE VIEW pull_requests_read_model AS
10
+ SELECT
11
+ pr.pr_key AS pr_key,
12
+ pr.repo AS repo,
13
+ pr.number AS number,
14
+ pr.url AS url,
15
+ pr.title AS title,
16
+ COALESCE(pr.derived_status, pr.status) AS status,
17
+ pr.current_round AS current_round,
18
+ pr.process_key AS process_key,
19
+ pr.waiting_since AS waiting_since,
20
+ pr.last_review_id AS last_review_id,
21
+ pr.outcome AS outcome,
22
+ pr.created_at AS created_at,
23
+ pr.updated_at AS updated_at,
24
+ pr.converged_at AS converged_at,
25
+ pr.merged_at AS merged_at,
26
+ pr.active_worker AS active_worker,
27
+ pr.lease_until AS lease_until,
28
+ pr.last_nudge_at AS last_nudge_at,
29
+ pr.fresh_head_run_head AS fresh_head_run_head,
30
+ pr.abandon_token AS abandon_token,
31
+ pr.incident_key AS incident_key,
32
+ pr.incident_type AS incident_type,
33
+ pr.incident_since AS incident_since,
34
+ pr.incident_message AS incident_message,
35
+ pr.last_round_head AS last_round_head,
36
+ pr.last_progress_job_key AS last_progress_job_key,
37
+ pr.last_progress_result AS last_progress_result,
38
+ pr.last_progress_agent_watermark AS last_progress_agent_watermark,
39
+ pr.churn_escalated_round AS churn_escalated_round,
40
+ pr.root_request_key AS root_request_key,
41
+ pr.epic_phase_label AS epic_phase_label,
42
+ pr.acknowledged_at AS acknowledged_at,
43
+ pr.auto_resolve_escalations AS auto_resolve_escalations,
44
+ pr.termination_reason AS termination_reason,
45
+ pr.termination_process_key AS termination_process_key,
46
+ pr.retire_pending_key AS retire_pending_key,
47
+ pr.merge_requested AS merge_requested,
48
+ pr.merge_human_approval AS merge_human_approval,
49
+ pr.custom_instructions AS custom_instructions,
50
+ pr.custom_instructions_text AS custom_instructions_text,
51
+ 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,
52
+ 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
53
+ FROM pull_requests__tracking pr;
@@ -76,6 +76,7 @@ curl -sS -X POST __BASE__/actions/start/convergence-loop \
76
76
  | `maxRounds` | integer | round cap before escalating (1–100; default `NANO_PR_MAX_ROUNDS`, 20). |
77
77
  | `dependsOn` | string[] | `prKey`s that must land before this one merges. |
78
78
  | `autoResolveEscalations` | boolean | opt in to the escalation steward (§3). Default `false`. |
79
+ | `customInstructions` | string | OPTIONAL free-text steering appended to **every** converge round's agent prompt (review-round and adversarial-review) under a `## Operator custom instructions` block — e.g. `"commit as josh.wulf@camunda.com"`, `"don't touch file X"`, `"keep the diff small"`. Blank/whitespace is treated as absent (the prompt stays byte-identical to no-instructions); a non-string is a `400`. Persists across reconverge re-dispatches. Read it back as `hasCustomInstructions` on the PR's `/status` row. |
79
80
 
80
81
  Idempotent on the PR key: re-POSTing refreshes the record instead of starting a second
81
82
  loop. The 202 response echoes `prKey` and `processKey`.
package/openapi.yaml CHANGED
@@ -92,6 +92,7 @@ components:
92
92
  - activeWorker
93
93
  - leaseUntil
94
94
  - autoResolveEscalations
95
+ - hasCustomInstructions
95
96
  - merge
96
97
  - incident
97
98
  - terminationReason
@@ -172,6 +173,13 @@ components:
172
173
  Whether this PR's run opted into automatic escalation resolution by the escalation
173
174
  steward (issue #889). Derived from the subject row's durable `auto_resolve_escalations`
174
175
  flag, never a clobbered process variable.
176
+ hasCustomInstructions:
177
+ type: boolean
178
+ description: >-
179
+ Whether this run's converge agents carry operator custom instructions (issue #956) —
180
+ the loop was started with `customInstructions` and every round's agent prompt is being
181
+ steered. Served from the durable `custom_instructions` row projection that `submitPr`
182
+ stamps at submit/reopen; a pre-#956 (NULL) row reads false. Never an engine read.
175
183
  merge:
176
184
  type: object
177
185
  additionalProperties: false
@@ -2253,6 +2261,10 @@ components:
2253
2261
  autoResolveEscalations:
2254
2262
  type: boolean
2255
2263
  description: "Opt this loop's escalations into the escalation steward (issue #889) — it reviews each open escalation every 30 min and submits a recommended choice, recorded reviewably in Auto-resolutions. Default off."
2264
+ customInstructions:
2265
+ type: string
2266
+ maxLength: 8000
2267
+ description: "OPTIONAL free-text steering appended to every converge round's agent prompt (review-round and adversarial-review), like `FeatureStartByIssue.customInstructions` for the implementer. Blank is treated as absent; a non-string is a 400. Surfaced on `/status` as `hasCustomInstructions`."
2256
2268
  convergeOnly:
2257
2269
  type: boolean
2258
2270
  deprecated: true
@@ -2280,6 +2292,10 @@ components:
2280
2292
  autoResolveEscalations:
2281
2293
  type: boolean
2282
2294
  description: "Opt this loop's escalations into the escalation steward (issue #889) — it reviews each open escalation every 30 min and submits a recommended choice, recorded reviewably in Auto-resolutions. Default off."
2295
+ customInstructions:
2296
+ type: string
2297
+ maxLength: 8000
2298
+ description: "OPTIONAL free-text steering appended to every converge round's agent prompt (review-round and adversarial-review), like `FeatureStartByIssue.customInstructions` for the implementer. Blank is treated as absent; a non-string is a 400. Surfaced on `/status` as `hasCustomInstructions`."
2283
2299
  convergeOnly:
2284
2300
  type: boolean
2285
2301
  deprecated: true
@@ -5304,6 +5320,10 @@ paths:
5304
5320
  autoResolveEscalations:
5305
5321
  type: boolean
5306
5322
  description: "Opt this loop's escalations into the escalation steward (issue #889) — it reviews each open escalation every 30 min and submits a recommended choice, recorded reviewably in Auto-resolutions. Default off."
5323
+ customInstructions:
5324
+ type: string
5325
+ maxLength: 8000
5326
+ description: OPTIONAL free-text steering appended to every converge round's agent prompt (review-round and adversarial-review), like `FeatureStartByIssue.customInstructions` for the implementer. Blank is treated as absent; a non-string is a 400. Surfaced on `/status` as `hasCustomInstructions`.
5307
5327
  convergeOnly:
5308
5328
  type: boolean
5309
5329
  deprecated: true
@@ -5330,6 +5350,10 @@ paths:
5330
5350
  autoResolveEscalations:
5331
5351
  type: boolean
5332
5352
  description: "Opt this loop's escalations into the escalation steward (issue #889) — it reviews each open escalation every 30 min and submits a recommended choice, recorded reviewably in Auto-resolutions. Default off."
5353
+ customInstructions:
5354
+ type: string
5355
+ maxLength: 8000
5356
+ description: OPTIONAL free-text steering appended to every converge round's agent prompt (review-round and adversarial-review), like `FeatureStartByIssue.customInstructions` for the implementer. Blank is treated as absent; a non-string is a 400. Surfaced on `/status` as `hasCustomInstructions`.
5333
5357
  convergeOnly:
5334
5358
  type: boolean
5335
5359
  deprecated: true
@@ -66,6 +66,23 @@ test("returns 200 with a count + projected active PRs", async () => {
66
66
  assertEquals(r.body.prs[0].processKey, "9");
67
67
  });
68
68
 
69
+ test("surfaces hasCustomInstructions from the durable row projection (issue #956)", async () => {
70
+ // The operator can see that a loop is being steered without opening the engine: `submitPr` stamps
71
+ // the `custom_instructions` column (1/0) at submit/reopen, and `/status` serves it as a boolean.
72
+ // A legacy row (NULL — written before the projection existed) reads false, never an engine read.
73
+ const app = memApp([
74
+ { pr_key: "o/r#30", repo: "o/r", number: 30, url: "u30", title: "steered", status: "converging", current_round: 1, process_key: "PI-30", updated_at: "2026-01-03", custom_instructions: 1 },
75
+ { pr_key: "o/r#31", repo: "o/r", number: 31, url: "u31", title: "plain", status: "converging", current_round: 1, process_key: "PI-31", updated_at: "2026-01-02", custom_instructions: 0 },
76
+ { pr_key: "o/r#32", repo: "o/r", number: 32, url: "u32", title: "legacy", status: "converging", current_round: 1, process_key: "PI-32", updated_at: "2026-01-01" },
77
+ ]);
78
+ const res = (await handler(input(), app)) as any;
79
+ assertEquals(res.status, 200);
80
+ const byKey = (k: string) => res.body.prs.find((p: any) => p.prKey === k);
81
+ assertEquals(byKey("o/r#30").hasCustomInstructions, true, "a steered loop is visible");
82
+ assertEquals(byKey("o/r#31").hasCustomInstructions, false, "an unsteered loop reads false");
83
+ assertEquals(byKey("o/r#32").hasCustomInstructions, false, "a legacy (NULL) row reads false");
84
+ });
85
+
69
86
  test("carries the running app's identity as a top-level version object (issue #925)", async () => {
70
87
  // `/status` is the endpoint everyone polls, so it must answer "is the fix live?" on its own:
71
88
  // version.version + version.gitSha, derived from the SAME app/version.ts getter /version uses.
@@ -207,6 +207,86 @@ test("startConvergenceLoop → 400 on a non-boolean autoMerge (issue #916)", asy
207
207
  });
208
208
  });
209
209
 
210
+ // Issue #956: the convergence-loop start door accepts an optional `customInstructions` string and
211
+ // threads it to every converge round's agent prompt — mirroring the feature door. The capture
212
+ // harness above records `convergeOnly`; this one records `customInstructions` off the same
213
+ // createInstance variables.
214
+ function captureInstructions() {
215
+ const rows: Record<string, unknown>[] = [];
216
+ let captured: unknown;
217
+ const data = {
218
+ table: (_name: string, key: string) => ({
219
+ get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
220
+ find: (q: Record<string, unknown>) =>
221
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
222
+ insert: (r: Record<string, unknown>) => {
223
+ rows.push(r);
224
+ return Promise.resolve(r);
225
+ },
226
+ update: (k: unknown, patch: Record<string, unknown>) => {
227
+ const row = rows.find((r) => r[key] === k);
228
+ if (row) Object.assign(row, patch);
229
+ return Promise.resolve(row);
230
+ },
231
+ delete: (k: unknown) => {
232
+ const i = rows.findIndex((r) => r[key] === k);
233
+ if (i >= 0) rows.splice(i, 1);
234
+ return Promise.resolve();
235
+ },
236
+ }),
237
+ };
238
+ const engine = {
239
+ createInstance: (req: { variables?: Record<string, unknown> }) => {
240
+ captured = req.variables?.customInstructions;
241
+ return Promise.resolve({ processInstanceKey: "PI-1" });
242
+ },
243
+ };
244
+ return { app: { data, engine, log: noopLog() } as any as AppApi, get: () => captured };
245
+ }
246
+
247
+ test("startConvergenceLoop threads customInstructions (trimmed) into the loop's variables (#956)", async () => {
248
+ await withGithubOff(async () => {
249
+ const { app: capApp, get } = captureInstructions();
250
+ const res = await startConvergenceLoop(
251
+ input({ pr: "owner/repo#20", customInstructions: " commit as josh.wulf@camunda.com " }),
252
+ capApp,
253
+ );
254
+ assertEquals((res as any).status, 202);
255
+ assertEquals(get(), "commit as josh.wulf@camunda.com");
256
+ });
257
+ });
258
+
259
+ test("startConvergenceLoop seeds customInstructions null when omitted or blank (#956)", async () => {
260
+ await withGithubOff(async () => {
261
+ const omitted = captureInstructions();
262
+ const res = await startConvergenceLoop(input({ pr: "owner/repo#21" }), omitted.app);
263
+ assertEquals((res as any).status, 202);
264
+ assertEquals(omitted.get(), null, "omitted ⇒ null, so the appendPrompt FEEL skips the block");
265
+
266
+ const blank = captureInstructions();
267
+ const blankRes = await startConvergenceLoop(
268
+ input({ pr: "owner/repo#22", customInstructions: " " }),
269
+ blank.app,
270
+ );
271
+ assertEquals((blankRes as any).status, 202);
272
+ assertEquals(blank.get(), null, "blank/whitespace ⇒ null (byte-identical prompt to today)");
273
+ });
274
+ });
275
+
276
+ // Mirrors the `autoMerge` non-boolean 400 (issue #916): a present-but-non-string `customInstructions`
277
+ // must not be silently dropped — the operator believes they steered the loop when nothing was
278
+ // appended. Rejected at the edge with a 400.
279
+ test("startConvergenceLoop → 400 on a non-string customInstructions (#956)", async () => {
280
+ await withGithubOff(async () => {
281
+ const { app: capApp, get } = captureInstructions();
282
+ const res = await startConvergenceLoop(input({ pr: "owner/repo#23", customInstructions: 42 }), capApp);
283
+ const r = res as any;
284
+ assertEquals(r.status, 400);
285
+ assertEquals(typeof r.body.error, "string");
286
+ assertEquals(get(), undefined, "a rejected door never reaches the engine");
287
+ });
288
+ });
289
+
210
290
  test("startPlanFanout → 400 on an unparseable issue reference", async () => {
211
291
  const res = await startPlanFanout(input({ issue: "" }), app);
212
292
  const r = res as any;
@@ -44,7 +44,18 @@ export default defineOperation("startConvergenceLoop", async ({ body }, app) =>
44
44
  // Opt-in escalation steward (issue #889): only when explicitly requested do this loop's
45
45
  // escalations become eligible for steward auto-resolution. Default off.
46
46
  const autoResolveEscalations = "autoResolveEscalations" in body && body.autoResolveEscalations === true;
47
- const result = await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds, false, null, false, autoResolveEscalations, autoMerge);
47
+ // Optional operator steering (issue #956), mirrored from the feature door: a present-but-non-string
48
+ // `customInstructions` is a 400 (the same way `autoMerge` is validated, #916) — silently dropping it
49
+ // would leave the operator believing they steered the loop when nothing was appended. Blank is
50
+ // normalized to null downstream (submitPr) so the prompt stays byte-identical to no-instructions.
51
+ if ("customInstructions" in body && typeof body.customInstructions !== "string") {
52
+ app.log.warn("start-convergence rejected: customInstructions must be a string", { customInstructions: body.customInstructions });
53
+ return { status: 400, body: { error: "customInstructions must be a string" } };
54
+ }
55
+ const customInstructions = "customInstructions" in body && typeof body.customInstructions === "string"
56
+ ? body.customInstructions
57
+ : null;
58
+ const result = await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds, false, null, false, autoResolveEscalations, autoMerge, null, null, customInstructions);
48
59
  app.log.info("convergence loop started", {
49
60
  prKey: parsed.prKey,
50
61
  alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
@@ -52,6 +63,7 @@ export default defineOperation("startConvergenceLoop", async ({ body }, app) =>
52
63
  maxRounds,
53
64
  autoMerge: autoMerge ?? body.convergeOnly !== true,
54
65
  autoResolveEscalations,
66
+ hasCustomInstructions: customInstructions != null && customInstructions.trim() !== "",
55
67
  });
56
68
  return { status: 202, body: result };
57
69
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.209.0",
3
+ "version": "0.210.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",
@@ -136,12 +136,17 @@
136
136
  },
137
137
  {
138
138
  "key": "autoMerge",
139
- "label": "Auto-merge \u2014 after convergence, drive the merge-loop",
139
+ "label": "Auto-merge — after convergence, drive the merge-loop",
140
140
  "type": "checkbox"
141
141
  },
142
+ {
143
+ "key": "customInstructions",
144
+ "label": "Custom instructions (optional) — extra steering appended to every converge round's agent prompt, e.g. “commit as josh.wulf@camunda.com; keep the diff small; don't touch file X”",
145
+ "type": "text"
146
+ },
142
147
  {
143
148
  "key": "autoResolveEscalations",
144
- "label": "Automatically resolve escalations \u2014 the opt-in escalation steward reviews this loop's open escalations every 30 min and submits a recommended choice, recorded reviewably in Auto-resolutions",
149
+ "label": "Automatically resolve escalations — the opt-in escalation steward reviews this loop's open escalations every 30 min and submits a recommended choice, recorded reviewably in Auto-resolutions",
145
150
  "type": "checkbox"
146
151
  }
147
152
  ]
@@ -149,7 +149,7 @@
149
149
  <zeebe:property name="io.nanobpm.dataEnvelope.out" value="PrReviewRoundOut" />
150
150
  </zeebe:properties>
151
151
  <zeebe:ioMapping>
152
- <zeebe:input source="=(if (abandonBrief = null) then &#34;&#34; else abandonBrief)" target="appendPrompt" />
152
+ <zeebe:input source="=(if (abandonBrief = null) then &#34;&#34; else abandonBrief) + (if (is defined(customInstructions) = false or customInstructions = null) then &#34;&#34; else &#34;&#10;&#10;---&#10;&#10;## Operator custom instructions&#10;&#10;The operator supplied these instructions for this run — follow them:&#10;&#10;&#34; + customInstructions)" target="appendPrompt" />
153
153
  <zeebe:input source="=round" target="guardRound" />
154
154
  <zeebe:input source="=maxRounds" target="guardMaxRounds" />
155
155
  <zeebe:input source="=advPass" target="guardAdvPass" />
@@ -169,6 +169,7 @@
169
169
  <zeebe:input source="=roundEntryHead" target="guardRoundEntryHead" />
170
170
  <zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
171
171
  <zeebe:input source="=abandonBrief" target="guardAbandonBrief" />
172
+ <zeebe:input source="=customInstructions" target="guardCustomInstructions" />
172
173
  <zeebe:output source="=null" target="answer" />
173
174
  <zeebe:output source="=false" target="scopePending" />
174
175
  <zeebe:output source="=null" target="adversarialFindings" />
@@ -191,6 +192,7 @@
191
192
  <zeebe:output source="=guardRoundEntryHead" target="roundEntryHead" />
192
193
  <zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
193
194
  <zeebe:output source="=guardAbandonBrief" target="abandonBrief" />
195
+ <zeebe:output source="=guardCustomInstructions" target="customInstructions" />
194
196
  </zeebe:ioMapping>
195
197
  </bpmn:extensionElements>
196
198
  <bpmn:incoming>f_capture</bpmn:incoming>
@@ -245,7 +247,7 @@
245
247
  <zeebe:property name="io.nanobpm.dataEnvelope.out" value="PrAdversarialReviewOut" />
246
248
  </zeebe:properties>
247
249
  <zeebe:ioMapping>
248
- <zeebe:input source="=(if (abandonBrief = null) then &#34;&#34; else abandonBrief)" target="appendPrompt" />
250
+ <zeebe:input source="=(if (abandonBrief = null) then &#34;&#34; else abandonBrief) + (if (is defined(customInstructions) = false or customInstructions = null) then &#34;&#34; else &#34;&#10;&#10;---&#10;&#10;## Operator custom instructions&#10;&#10;The operator supplied these instructions for this run — follow them:&#10;&#10;&#34; + customInstructions)" target="appendPrompt" />
249
251
  <zeebe:input source="=round" target="guardRound" />
250
252
  <zeebe:input source="=maxRounds" target="guardMaxRounds" />
251
253
  <zeebe:input source="=advPass" target="guardAdvPass" />
@@ -265,6 +267,7 @@
265
267
  <zeebe:input source="=roundEntryHead" target="guardRoundEntryHead" />
266
268
  <zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
267
269
  <zeebe:input source="=abandonBrief" target="guardAbandonBrief" />
270
+ <zeebe:input source="=customInstructions" target="guardCustomInstructions" />
268
271
  <zeebe:output source="=adversarialFindings" target="adversarialFindings" />
269
272
  <zeebe:output source="=(if guardAdvPass = null then 0 else guardAdvPass) + 1" target="advPass" />
270
273
  <zeebe:output source="=(if adversarialFindings != null and trim(adversarialFindings) != &#34;&#34; then null else status)" target="status" />
@@ -288,6 +291,7 @@
288
291
  <zeebe:output source="=guardRoundEntryHead" target="roundEntryHead" />
289
292
  <zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
290
293
  <zeebe:output source="=guardAbandonBrief" target="abandonBrief" />
294
+ <zeebe:output source="=guardCustomInstructions" target="customInstructions" />
291
295
  </zeebe:ioMapping>
292
296
  </bpmn:extensionElements>
293
297
  <bpmn:incoming>f_advDue</bpmn:incoming>
@@ -579,6 +583,7 @@
579
583
  <zeebe:input source="=roundEntryHead" target="guardRoundEntryHead" />
580
584
  <zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
581
585
  <zeebe:input source="=abandonBrief" target="guardAbandonBrief" />
586
+ <zeebe:input source="=customInstructions" target="guardCustomInstructions" />
582
587
  <zeebe:output source="=null" target="scopeAnswer" />
583
588
  <zeebe:output source="=guardRound" target="round" />
584
589
  <zeebe:output source="=guardMaxRounds" target="maxRounds" />
@@ -599,6 +604,7 @@
599
604
  <zeebe:output source="=guardRoundEntryHead" target="roundEntryHead" />
600
605
  <zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
601
606
  <zeebe:output source="=guardAbandonBrief" target="abandonBrief" />
607
+ <zeebe:output source="=guardCustomInstructions" target="customInstructions" />
602
608
  </zeebe:ioMapping>
603
609
  </bpmn:extensionElements>
604
610
  <bpmn:incoming>f_convergeOk</bpmn:incoming>
@@ -19,6 +19,7 @@
19
19
  <nano:extend name="prKey" type="string" optional="true" />
20
20
  <nano:extend name="autoMerge" type="boolean" optional="true" />
21
21
  <nano:extend name="humanApproval" type="boolean" optional="true" />
22
+ <nano:extend name="customInstructions" type="string" optional="true" />
22
23
  </nano:shape>
23
24
  </nano:shapes>
24
25
  </bpmn:extensionElements>
@@ -55,6 +55,7 @@
55
55
  <nano:extend name="failingChecksList" type="string" optional="true" />
56
56
  <nano:extend name="reviewThreads" type="integer" optional="true" />
57
57
  <nano:extend name="pendingChecks" type="integer" optional="true" />
58
+ <nano:extend name="updatePending" type="boolean" optional="true" />
58
59
  </nano:shape>
59
60
  <nano:shape id="LandedProbeIn" name="Landed probe — input">
60
61
  <nano:extend name="prKey" type="string" />
@@ -476,8 +477,9 @@
476
477
  <zeebe:property name="io.nanobpm.dataEnvelope.out" value="FixCiOut" />
477
478
  </zeebe:properties>
478
479
  <zeebe:ioMapping>
479
- <zeebe:input source="=(if (failingChecksList = null or failingChecksList = &#34;&#34;) then &#34;&#34; else &#34;&#10;&#10;Failing checks to make green:&#10;&#34; + failingChecksList) + (if (abandonBrief = null) then &#34;&#34; else abandonBrief)" target="appendPrompt" />
480
+ <zeebe:input source="=(if (failingChecksList = null or failingChecksList = &#34;&#34;) then &#34;&#34; else &#34;&#10;&#10;Failing checks to make green:&#10;&#34; + failingChecksList) + (if (abandonBrief = null) then &#34;&#34; else abandonBrief) + (if (is defined(customInstructions) = false or customInstructions = null) then &#34;&#34; else &#34;&#10;&#10;---&#10;&#10;## Operator custom instructions&#10;&#10;The operator supplied these instructions for this run — follow them:&#10;&#10;&#34; + customInstructions)" target="appendPrompt" />
480
481
  <zeebe:input source="=ciFixRound" target="guardCiFixRound" />
482
+ <zeebe:input source="=customInstructions" target="guardCustomInstructions" />
481
483
  <zeebe:input source="=ciFixMax" target="guardCiFixMax" />
482
484
  <zeebe:input source="=rebaseRound" target="guardRebaseRound" />
483
485
  <zeebe:input source="=rebaseMax" target="guardRebaseMax" />
@@ -521,6 +523,7 @@
521
523
  <zeebe:output source="=guardRound" target="round" />
522
524
  <zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
523
525
  <zeebe:output source="=guardAbandonBrief" target="abandonBrief" />
526
+ <zeebe:output source="=guardCustomInstructions" target="customInstructions" />
524
527
  <zeebe:output source="=guardReviewRequestRound" target="reviewRequestRound" />
525
528
  <zeebe:output source="=guardReviewRequestMax" target="reviewRequestMax" />
526
529
  <zeebe:output source="=guardReviewRequestWait" target="reviewRequestWait" />
@@ -686,8 +689,9 @@
686
689
  <zeebe:property name="io.nanobpm.dataEnvelope.out" value="RebaseOut" />
687
690
  </zeebe:properties>
688
691
  <zeebe:ioMapping>
689
- <zeebe:input source="=(if (abandonBrief = null) then &#34;&#34; else abandonBrief)" target="appendPrompt" />
692
+ <zeebe:input source="=(if (abandonBrief = null) then &#34;&#34; else abandonBrief) + (if (is defined(customInstructions) = false or customInstructions = null) then &#34;&#34; else &#34;&#10;&#10;---&#10;&#10;## Operator custom instructions&#10;&#10;The operator supplied these instructions for this run — follow them:&#10;&#10;&#34; + customInstructions)" target="appendPrompt" />
690
693
  <zeebe:input source="=ciFixRound" target="guardCiFixRound" />
694
+ <zeebe:input source="=customInstructions" target="guardCustomInstructions" />
691
695
  <zeebe:input source="=ciFixMax" target="guardCiFixMax" />
692
696
  <zeebe:input source="=rebaseRound" target="guardRebaseRound" />
693
697
  <zeebe:input source="=rebaseMax" target="guardRebaseMax" />
@@ -731,6 +735,7 @@
731
735
  <zeebe:output source="=guardRound" target="round" />
732
736
  <zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
733
737
  <zeebe:output source="=guardAbandonBrief" target="abandonBrief" />
738
+ <zeebe:output source="=guardCustomInstructions" target="customInstructions" />
734
739
  <zeebe:output source="=guardReviewRequestRound" target="reviewRequestRound" />
735
740
  <zeebe:output source="=guardReviewRequestMax" target="reviewRequestMax" />
736
741
  <zeebe:output source="=guardReviewRequestWait" target="reviewRequestWait" />
@@ -846,7 +851,7 @@
846
851
  <bpmn:sequenceFlow id="f_mw_stall" sourceRef="wait-mergeable-timeout" targetRef="merge-stall-probe" />
847
852
  <bpmn:sequenceFlow id="f_msp_gate" sourceRef="merge-stall-probe" targetRef="gw-merge-stall" />
848
853
  <bpmn:sequenceFlow id="f_msp_rederive" name="re-derived — route to arm" sourceRef="gw-merge-stall" targetRef="gw-mergeable">
849
- <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeStallRounds &lt;= mergeStallMax</bpmn:conditionExpression>
854
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeStallRounds &lt;= mergeStallMax or mergeState = "ready" or updatePending = true</bpmn:conditionExpression>
850
855
  </bpmn:sequenceFlow>
851
856
  <bpmn:sequenceFlow id="f_msp_giveup" name="stall budget exhausted" sourceRef="gw-merge-stall" targetRef="merge-esc-conflict" />
852
857
  <bpmn:sequenceFlow id="f_m_ready" sourceRef="wait-mergeable" targetRef="gw-mergeable" />
@@ -53,13 +53,22 @@ const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
53
53
  // read the durable flag off the `feature_runs` row (the source of truth) and hand it to `submitPr`.
54
54
  const featureRow = await featureRuns(app.data).get(featureKey);
55
55
  const autoResolve = featureRow?.auto_resolve_escalations === 1;
56
- await submitPr(app.data, app.engine, parsed, [], MAX_ROUNDS, false, featureKey, humanApproval, autoResolve, autoMerge);
56
+ // Propagate the run's operator custom instructions (issue #956) the same way. Read the DURABLE
57
+ // text off the `feature_runs` row — persisted by `startFeature` before the instance/handoff
58
+ // existed (issue #956 review) — NOT the job variable: the worker publishes `converging` BEFORE
59
+ // enrolling, so `pollFeatureDelivery`'s re-enroll edge can win the race (or recover an
60
+ // interrupted handoff) with no job variables at all. Both paths now read this one durable source,
61
+ // so the steering survives the worker/poller interleaving.
62
+ const rowInstructions = featureRow?.custom_instructions;
63
+ const customInstructions = typeof rowInstructions === "string" && rowInstructions.trim() !== "" ? rowInstructions : null;
64
+ await submitPr(app.data, app.engine, parsed, [], MAX_ROUNDS, false, featureKey, humanApproval, autoResolve, autoMerge, null, null, customInstructions);
57
65
  app.log.info("converge-feature: enrolled PR into convergence loop", {
58
66
  featureKey,
59
67
  prKey: parsed.prKey,
60
68
  autoMerge,
61
69
  humanApproval,
62
70
  autoResolveEscalations: autoResolve,
71
+ hasCustomInstructions: customInstructions != null,
63
72
  });
64
73
  return {};
65
74
  };