@nanobpm/nano-workforce 0.200.8 → 0.200.9

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.
@@ -18,6 +18,19 @@ The job payload (stdin JSON) carries:
18
18
 
19
19
  You have `gh` / git authenticated for the target repository.
20
20
 
21
+ ## Workspace confinement — work ONLY inside your run workspace
22
+
23
+ Your run has a private workspace directory (the git repository you start in — the
24
+ clone/worktree the harness checked out for you). **Do all your work there.** Never
25
+ `cd /tmp/<repo>`, clone into a shared path, or edit files outside your workspace:
26
+ that directory is torn down when your run ends, so edits made elsewhere are
27
+ **silently lost** (and can collide with a sibling run sharing the path). If you
28
+ need the repo, use the one you are already in — do not clone a second copy into
29
+ `/tmp`. A completion that leaves no commit on `feat/<task.id>` and no PR is treated
30
+ as a **failed, undelivered attempt** (see the reconcile/escalation behaviour the
31
+ app applies), so your work must land in a pushed commit on your branch, nowhere
32
+ else.
33
+
21
34
  ## Your branch (deterministic — the same across a resume)
22
35
 
23
36
  Always use the branch **`feat/<task.id>`**. Because a resumed run gets a fresh
@@ -192,7 +205,9 @@ Write a JSON object of **result variables** to the file named by the
192
205
 
193
206
  Rules:
194
207
 
195
- - `status` — one of:
208
+ - `status` — one of EXACTLY these four tokens (any other value — e.g.
209
+ `completed`, `done`, `success` — is **off-vocabulary**: the app cannot route on
210
+ it and will treat your slice as **not delivered**, so never invent one):
196
211
  - `opened` — a PR was created (ready for review). Set `pr`.
197
212
  - `escalated` — you need a human decision; set `question`, and set `pr` to the
198
213
  **draft** PR you opened to preserve your work (if you managed to open one).
@@ -28,7 +28,7 @@ test("adopts an open PR on the branch: emits reconciled + status=opened + pr", a
28
28
  { jobKey: "j1", variables: { subjectKey: "owner/repo#801", task: { id: "issue-801" }, status: null } } as never,
29
29
  fakeApp,
30
30
  );
31
- assertEquals(out, { reconciled: true, status: "opened", pr: "owner/repo#801" });
31
+ assertEquals(out, { reconciled: true, status: "opened", pr: "owner/repo#801", retry: false, retryNudge: null, retried: false, deliveryVerified: true });
32
32
  } finally {
33
33
  globalThis.fetch = prevFetch;
34
34
  if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
@@ -54,7 +54,7 @@ test("wires baseBranch through: a wrong-base open PR is NOT adopted → escalate
54
54
  { jobKey: "j3", variables: { subjectKey: "owner/repo#7", task: { id: "issue-7" }, status: null, baseBranch: "epic/feat-x" } } as never,
55
55
  fakeApp,
56
56
  );
57
- assertEquals(out, { reconciled: false, status: null, pr: null });
57
+ assertEquals(out, { reconciled: false, status: null, pr: null, retry: false, retryNudge: null, retried: false, deliveryVerified: true });
58
58
  } finally {
59
59
  globalThis.fetch = prevFetch;
60
60
  if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
@@ -76,7 +76,35 @@ test("no open PR on the branch: falls through to escalate (reconciled=false)", a
76
76
  { jobKey: "j2", variables: { subjectKey: "owner/repo#7", task: { id: "issue-7" }, status: null } } as never,
77
77
  fakeApp,
78
78
  );
79
- assertEquals(out, { reconciled: false, status: null, pr: null });
79
+ assertEquals(out, { reconciled: false, status: null, pr: null, retry: false, retryNudge: null, retried: false, deliveryVerified: true });
80
+ } finally {
81
+ globalThis.fetch = prevFetch;
82
+ if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
83
+ else process.env.GITHUB_TOKEN = prevToken;
84
+ if (prevTransport === undefined) delete process.env.NANO_PR_GITHUB_TRANSPORT;
85
+ else process.env.NANO_PR_GITHUB_TRANSPORT = prevTransport;
86
+ }
87
+ });
88
+
89
+ test("claimed-completion (off-vocabulary status) + no PR → auto-retry once with a nudge (issue #865)", async () => {
90
+ const prevToken = process.env.GITHUB_TOKEN;
91
+ const prevTransport = process.env.NANO_PR_GITHUB_TRANSPORT;
92
+ const prevFetch = globalThis.fetch;
93
+ process.env.GITHUB_TOKEN = "t";
94
+ process.env.NANO_PR_GITHUB_TRANSPORT = "token";
95
+ globalThis.fetch = (async () => new Response(JSON.stringify([]), { status: 200 })) as typeof fetch;
96
+ try {
97
+ const out = await handler(
98
+ {
99
+ jobKey: "j4",
100
+ variables: { subjectKey: "owner/repo#41", task: { id: "issue-41" }, status: "completed" },
101
+ } as never,
102
+ fakeApp,
103
+ );
104
+ assertEquals(out.reconciled, false);
105
+ assertEquals(out.retry, true);
106
+ assertEquals(out.retried, true);
107
+ assertEquals(typeof out.retryNudge === "string" && out.retryNudge.length > 0, true);
80
108
  } finally {
81
109
  globalThis.fetch = prevFetch;
82
110
  if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
@@ -28,6 +28,8 @@ interface In extends Record<string, unknown> {
28
28
  status?: unknown;
29
29
  pr?: unknown;
30
30
  baseBranch?: unknown;
31
+ question?: unknown;
32
+ implementRetried?: unknown;
31
33
  }
32
34
 
33
35
  /** The cell's deterministic branch is `feat/<task.id>`; `task` is the implement-cell's slice object.
@@ -45,6 +47,8 @@ const handler: AppJobHandler<In, ReconcileImplementResult> = async (job, app) =>
45
47
  taskId: taskId(job.variables.task),
46
48
  pr: job.variables.pr,
47
49
  baseBranch: job.variables.baseBranch,
50
+ question: job.variables.question,
51
+ retried: job.variables.implementRetried,
48
52
  },
49
53
  listPrsForHead,
50
54
  process.env.GITHUB_TOKEN ?? "",
@@ -52,6 +56,7 @@ const handler: AppJobHandler<In, ReconcileImplementResult> = async (job, app) =>
52
56
  app.log.info("reconcile-implement", {
53
57
  subjectKey: job.variables.subjectKey ?? null,
54
58
  reconciled: res.reconciled,
59
+ retry: res.retry,
55
60
  pr: res.pr,
56
61
  });
57
62
  return res;
@@ -97,14 +97,43 @@ test("no-result: a missing status/question synthesises the #360 answerable quest
97
97
  assertEquals(app.stores.feature_escalations[0].question, NO_RESULT_QUESTION);
98
98
  });
99
99
 
100
- test("blank-question escalated: still synthesises the #360 question (never a dead-end task)", async () => {
100
+ test("blank-question escalated: synthesises an ACCURATE reported-status reason, not the false no-result text (#865 review)", async () => {
101
+ // A reported `escalated` with a blank question reaches the builder. It must name the reported status
102
+ // accurately — never the false "no status was reported" (no-result) diagnosis — and never dead-end.
101
103
  const app = fakeApp([]);
102
104
  const out = await handler(
103
105
  { jobKey: "job-b", variables: { subjectKey: "owner/repo#2", status: "escalated", question: " " } } as never,
104
106
  app,
105
107
  );
106
- assertEquals(out, { question: NO_RESULT_QUESTION });
107
- assertEquals(app.stores.feature_escalations[0].question, NO_RESULT_QUESTION);
108
+ assertEquals(out.question.includes('reported status "escalated"'), true);
109
+ assertEquals(out.question.includes("gave no answerable question"), true);
110
+ assertEquals(out.question.includes("without a machine-readable result"), false);
111
+ assertEquals(app.stores.feature_escalations[0].question, out.question);
112
+ });
113
+
114
+ test("escalated with a question + summary + transcript: preserves ALL context, not just the question (#865 review)", async () => {
115
+ // The #865-review defect: an `escalated` result carrying a question, summary, and transcript persisted
116
+ // ONLY the question, leaving pollUserTasks without the supporting decision context.
117
+ const rows = [{ feature_key: "owner/repo#9", status: "running" }];
118
+ const app = fakeApp(rows);
119
+ const out = await handler(
120
+ {
121
+ jobKey: "job-9",
122
+ variables: {
123
+ subjectKey: "owner/repo#9",
124
+ status: "escalated",
125
+ question: "Should I target the epic base or main?",
126
+ summary: "drafted the migration but the base is ambiguous",
127
+ transcriptUrl: "http://merlin.local:3000/t?stream=job%3A777",
128
+ },
129
+ } as never,
130
+ app,
131
+ );
132
+ assertEquals(out.question.startsWith("Should I target the epic base or main?"), true);
133
+ assertEquals(out.question.includes('The agent\'s own summary: "drafted the migration but the base is ambiguous".'), true);
134
+ assertEquals(out.question.includes("Transcript: http://merlin.local:3000/t?stream=job%3A777"), true);
135
+ assertEquals(rows[0].status, "escalated");
136
+ assertEquals(app.stores.feature_escalations[0].question, out.question);
108
137
  });
109
138
 
110
139
  test("fails fast (incident) when subjectKey is absent — never appends a corrupt undefined-keyed audit row", async () => {
@@ -132,3 +161,29 @@ test("a retried job (same jobKey) reuses its audit row, never duplicating", asyn
132
161
  assertEquals(app.stores.feature_escalations.length, 1, "the retry reuses the row, no duplicate append");
133
162
  assertEquals(app.stores.feature_escalations[0].job_key, "job-retry");
134
163
  });
164
+
165
+ test("claimed-completion (off-vocabulary status) with no question: accurate reason, not the false no-result text (issue #865)", async () => {
166
+ // The #865 defect reproduction: the agent reported `status: "completed"` with a summary but no PR.
167
+ // The synthesised reason must name the reported status + fold in the summary and transcript link —
168
+ // never the false "no status was reported" diagnosis.
169
+ const rows = [{ feature_key: "owner/repo#41", status: "running" }];
170
+ const app = fakeApp(rows);
171
+ const out = await handler(
172
+ {
173
+ jobKey: "job-41",
174
+ variables: {
175
+ subjectKey: "owner/repo#41",
176
+ status: "completed",
177
+ summary: "implemented src/pin.rs and supporting changes",
178
+ transcriptUrl: "http://merlin.local:3000/t?stream=job%3A215572",
179
+ },
180
+ } as never,
181
+ app,
182
+ );
183
+ assertEquals(out.question.includes('reported status "completed"'), true);
184
+ assertEquals(out.question.includes("without a machine-readable result"), false);
185
+ assertEquals(out.question.includes('The agent\'s own summary: "implemented src/pin.rs and supporting changes".'), true);
186
+ assertEquals(out.question.includes("Transcript: http://merlin.local:3000/t?stream=job%3A215572"), true);
187
+ assertEquals(rows[0].status, "escalated");
188
+ assertEquals(app.stores.feature_escalations[0].question, out.question);
189
+ });
@@ -25,6 +25,7 @@
25
25
  import type { AppJobHandler } from "@nanobpm/urban";
26
26
  import { classifyEscalation } from "../../app/escalationTaxonomy.ts";
27
27
  import { featureRuns, recordFeatureEscalation } from "../../app/feature.ts";
28
+ import { implementEscalationQuestion, NO_RESULT_QUESTION } from "../../app/implementEscalationReason.ts";
28
29
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
29
30
 
30
31
  // Input typed off the model data envelope (`RecordEscalationIn` in implement-cell.bpmn) — ADR 0040.
@@ -36,12 +37,6 @@ interface Out extends Record<string, unknown> {
36
37
  const str = (v: unknown): string | undefined =>
37
38
  typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
38
39
 
39
- // The answerable prompt synthesised when the agent left no usable question — the implement-stage
40
- // analogue of record-trial-merge's synthesised trial-merge question. It names the recoverable work (a
41
- // PR may exist on the slice's branch) and the two answers the cell's `ic_gw_answer` gateway routes on.
42
- const NO_RESULT_QUESTION =
43
- 'The implementation agent finished without a machine-readable result (no status was reported), so we cannot tell whether the slice succeeded. It may still have opened a PR (check for a branch targeting the epic base). Choose "Answer" and give guidance to re-run the slice — or choose "Abandon" to skip it and continue.';
44
-
45
40
  const handler: AppJobHandler<In, Out> = async (job, app) => {
46
41
  const subjectKey = job.variables.subjectKey;
47
42
  // Fail fast rather than keying the `feature_escalations` audit log (and the guarded `feature_runs`
@@ -61,11 +56,30 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
61
56
  // so the parked task is never a dead end. Route through the single canonical taxonomy so this net can
62
57
  // never drift from the tier logic every other raise site uses. A "task"-kind escalation is
63
58
  // `decision-required` only when the agent left an answerable question, so this already covers the
64
- // blank-question case; the extra `&& rawQuestion` is the type narrowing that lets us hand the string
65
- // through without an assertion.
59
+ // blank-question case.
66
60
  const agentEscalated = classifyEscalation({ kind: "task", status: job.variables.status, question: rawQuestion }) ===
67
61
  "decision-required";
68
- const question = agentEscalated && rawQuestion ? rawQuestion : NO_RESULT_QUESTION;
62
+ // Build the escalation question through the single canonical builder (issue #865), ALWAYS — including
63
+ // when the agent raised its own answerable question. Passing the agent's `question` lets the builder
64
+ // lead with it AND fold in the agent's `summary` + transcript as supporting context (#865 review —
65
+ // otherwise an `escalated` result's summary/transcript were dropped and only the bare question
66
+ // survived). With no answerable question it synthesises an accurate reason — distinguishing a true
67
+ // no-result (blank status) from a claimed-completion-without-delivery (an affirmative-completion alias
68
+ // such as `completed`) and from a reported non-completion status — never the false "no status was
69
+ // reported" diagnosis for a reported `escalated`/`failed` (#865 review). The reason can never drift
70
+ // from the reconcile step because both read the same canonical builder.
71
+ const question = implementEscalationQuestion({
72
+ status: job.variables.status,
73
+ question: rawQuestion,
74
+ summary: job.variables.summary,
75
+ transcriptUrl: job.variables.transcriptUrl,
76
+ // Delivery EVIDENCE (#865 review): the preserved PR (an `escalated`/`failed` with a blank question
77
+ // retains it — never claim "opened no pull request") and whether the reconcile step's GitHub lookup
78
+ // actually CONFIRMED the delivery state (only then may the reason say "none was found"). Both are
79
+ // process variables the upstream `reconcile-implement` step emits into scope.
80
+ pr: job.variables.pr,
81
+ deliveryVerified: job.variables.deliveryVerified,
82
+ });
69
83
 
70
84
  // Append to the canonical `feature_escalations` audit log (the surviving table `pollUserTasks` reads),
71
85
  // keyed by `subjectKey` — the feature run's `feature_key`, or the epic's `plan_key` for a wave slice.
@@ -79,7 +93,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
79
93
  await runs.update(subjectKey, { status: "escalated", updated_at: new Date().toISOString() });
80
94
  }
81
95
 
82
- app.log.info("record-escalation", { subjectKey, synthesised: question === NO_RESULT_QUESTION });
96
+ app.log.info("record-escalation", { subjectKey, synthesised: !(agentEscalated && rawQuestion) });
83
97
 
84
98
  // Re-emit the resolved question so the `feature-escalation` form (and the answer loop) see it.
85
99
  return { question };