@nanobpm/nano-workforce 0.191.1 → 0.192.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/test/worldDb.ts CHANGED
@@ -118,5 +118,5 @@ export function memDataFor(migrationFiles: readonly string[]): { data: DataLayer
118
118
 
119
119
  /** A `DataLayer` stub over a fresh in-memory db with the world schema applied. */
120
120
  export function memWorldData(): { data: DataLayer; db: DatabaseSync } {
121
- return memDataFor(["049_world_checkpoint.sql"]);
121
+ return memDataFor(["049_world_checkpoint.sql", "115_world_checkpoint_process_key.sql"]);
122
122
  }
@@ -202,6 +202,12 @@ const handler: AppJobHandler<In> = async (job, app) => {
202
202
  prKey,
203
203
  roundNo: round,
204
204
  commitSha: marker.commitSha,
205
+ // Bind the checkpoint to THIS convergence run (issue #819): the no-advance self-heal scopes
206
+ // its lookup by `(process_key, round_no)` so it can never fast-forward the head onto a prior
207
+ // run's stale checkpoint. `processInstanceKey` is this run's instance key — the same value
208
+ // `submitPr` advances into `pull_requests.process_key` and the self-heal's ownership guard
209
+ // compares against.
210
+ processKey: processInstanceKey,
205
211
  ...(marker.effects ? { effects: marker.effects } : {}),
206
212
  });
207
213
  } catch (err) {
@@ -17,9 +17,11 @@
17
17
  // transient GitHub hiccup can never fabricate a no-progress escalation.
18
18
  import type { AgentInstanceSummary, AppApi, AppJobHandler } from "@nanobpm/urban";
19
19
  import { type HeadReader, makeDefaultReadHead } from "../../app/currentHead.ts";
20
- import { fetchBranchHead, fetchPrHead } from "../../app/github.ts";
20
+ import { compareCommits, fetchBranchHead, fetchPrHead, updateBranchRef } from "../../app/github.ts";
21
21
  import { decideProgress, isAddressedStatus } from "../../app/roundProgress.ts";
22
22
  import { parsePr } from "../../app/service.ts";
23
+ import { WorldStore } from "../../app/world/index.ts";
24
+ import { attemptNoAdvanceSelfHeal, type SelfHealResult } from "../../app/world/selfHeal.ts";
23
25
  import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
24
26
 
25
27
  // Input/output typed off the model data envelopes (`PrProgressCheckIn` / `PrProgressCheckOut` in
@@ -75,6 +77,45 @@ export { type HeadReader, makeDefaultReadHead };
75
77
 
76
78
  const defaultReadHead: HeadReader = makeDefaultReadHead({ fetchPrHead, fetchBranchHead });
77
79
 
80
+ /** Attempt the no-advance self-heal (#818): reconcile a recorded push-checkpoint against the PR head
81
+ * and fast-forward the head onto it when safe. Injectable so tests never touch git/network; the
82
+ * default binds the real `WorldStore` (over `app.data`) + GitHub transport with the env credential
83
+ * (the same `process.env.GITHUB_TOKEN` default the head reader uses). See {@link
84
+ * ../../app/world/selfHeal.ts}. */
85
+ export type SelfHealFn = (
86
+ app: AppApi,
87
+ repo: string,
88
+ prNumber: number,
89
+ prKey: string,
90
+ currentHead: string,
91
+ roundNo: number,
92
+ processKey: string | null,
93
+ stillOwns: () => Promise<boolean>,
94
+ ) => Promise<SelfHealResult>;
95
+
96
+ const defaultSelfHeal: SelfHealFn = (app, repo, prNumber, prKey, currentHead, roundNo, processKey, stillOwns) => {
97
+ const tok = process.env.GITHUB_TOKEN ?? "";
98
+ const store = new WorldStore(app.data);
99
+ return attemptNoAdvanceSelfHeal(
100
+ {
101
+ lastCheckpoint: (pk, run, rn) => store.lastCheckpointForRun(pk, run, rn),
102
+ prHead: async (r, n) => {
103
+ const h = await fetchPrHead(r, n, tok);
104
+ return h ? { headRef: h.headRef, headRepo: h.headRepo } : null;
105
+ },
106
+ compare: (r, base, head) => compareCommits(r, base, head, tok),
107
+ advanceHead: (r, branch, sha, expected) => updateBranchRef(r, branch, sha, tok, expected),
108
+ stillOwns,
109
+ },
110
+ repo,
111
+ prNumber,
112
+ prKey,
113
+ currentHead,
114
+ roundNo,
115
+ processKey,
116
+ );
117
+ };
118
+
78
119
  /** An agent-instance is "durable work" for husk purposes once it has reached a terminal state — it
79
120
  * carries a `completionDate`, or a terminal lifecycle status. A husked instance never closes (it is
80
121
  * stuck `THINKING` with null dates — jwulf/c8ctl-plugin-nano#230), so it is NOT counted. */
@@ -203,6 +244,7 @@ function agentWorkFromEngine(engine: AppApi["engine"]): AgentWorkReader {
203
244
  export function makeHandler(deps: {
204
245
  readHead: HeadReader;
205
246
  readAgentWork?: AgentWorkReader;
247
+ selfHeal?: SelfHealFn;
206
248
  }): AppJobHandler<In, Out> {
207
249
  return async (job, app) => {
208
250
  const { prKey, status, repo, prNumber, round, huskRetries, roundEntryHead } = job.variables;
@@ -241,25 +283,30 @@ export function makeHandler(deps: {
241
283
  // the single writer of the review-wait park (#786) — persist-round no longer sets
242
284
  // `waiting_review`, so the row rests on its running `converging` status until exactly one write
243
285
  // here resolves it, and the poller never sees a transient `waiting_review` for a husk-retry round.
286
+ // PROCESS-INSTANCE FENCE (Copilot #789). A straggler progress-check from a SUPERSEDED
287
+ // convergence instance can slip past the replay guard (its idempotency stamp was cleared when
288
+ // `submitPr` re-opened the PR) and reach here AFTER `submitPr` has reset the per-run fields and
289
+ // started a NEW convergence instance. Its stale baseline / status / watermark / result must not
290
+ // clobber the fresh run (which could be parked on `waiting_review` or replay stale output) — nor
291
+ // may it mutate GitHub with the old run's checkpoint (see the self-heal fence below).
292
+ // Re-read the row's CURRENT owner and report whether this job's own `processInstanceKey` no
293
+ // longer owns the row — the straggler's token lives in a terminated instance. Fail open (not
294
+ // superseded) when either key is absent (an older instance, or a row whose `process_key` is not
295
+ // yet seeded) so normal single-run behaviour is untouched. Compare as strings — `process_key` is
296
+ // persisted via `String(processInstanceKey)`, and a job key can arrive numeric.
297
+ const isSuperseded = async (): Promise<boolean> => {
298
+ const currentOwner = (await prs.get(prKey))?.process_key ?? null;
299
+ const jobOwner = job.processInstanceKey ?? null;
300
+ return currentOwner !== null && jobOwner !== null && String(currentOwner) !== String(jobOwner);
301
+ };
244
302
  const commit = async (
245
303
  out: Out,
246
304
  opts: { head?: string | null; status?: "waiting_review" | "converging"; agentWatermark?: string | null },
247
305
  ): Promise<Out> => {
248
306
  const ts = new Date().toISOString();
249
- // PROCESS-INSTANCE FENCE (Copilot #789). A straggler progress-check from a SUPERSEDED
250
- // convergence instance can slip past the replay guard (its idempotency stamp was cleared when
251
- // `submitPr` re-opened the PR) and reach here AFTER `submitPr` has reset the per-run fields and
252
- // started a NEW convergence instance. Its stale baseline / status / watermark / result must not
253
- // clobber the fresh run (which could be parked on `waiting_review` or replay stale output).
254
- // Re-read the row's CURRENT owner immediately before the write and drop the write when this
255
- // job's own `processInstanceKey` no longer owns the row — the straggler's token lives in a
256
- // terminated instance, so acking without persisting is correct. Fail open when either key is
257
- // absent (an older instance, or a row whose `process_key` is not yet seeded) so normal
258
- // single-run behaviour is untouched. Compare as strings — `process_key` is persisted via
259
- // `String(processInstanceKey)`, and a job key can arrive numeric.
260
- const currentOwner = (await prs.get(prKey))?.process_key ?? null;
261
- const jobOwner = job.processInstanceKey ?? null;
262
- if (currentOwner && jobOwner && String(currentOwner) !== String(jobOwner)) {
307
+ // Re-check ownership immediately before the write and drop it when this job no longer owns the
308
+ // row — acking without persisting is correct (the straggler's token is in a terminated instance).
309
+ if (await isSuperseded()) {
263
310
  return out;
264
311
  }
265
312
  await prs.update(prKey, {
@@ -386,9 +433,61 @@ export function makeHandler(deps: {
386
433
  // once the husk retry has been ruled out, so a husk-retry round never transits `waiting_review`.
387
434
  return commit(out, { head: currentHead, status: "waiting_review", agentWatermark });
388
435
  }
389
- // The remaining outcome (progressed:false, huskRetry:false) escalates to a human; the
390
- // persist-escalation-noprogress worker owns the status, so leave it unset — but still advance the
391
- // baseline and stamp the idempotency record so a redelivered escalation replays instead of
436
+
437
+ // NO-ADVANCE SELF-HEAL (#818, Layer 2). Before escalating to a human, reconcile the round's work
438
+ // against the remote: a producer harness that provisioned the review shape wrong pushed the fix
439
+ // to a throwaway fallback branch, so it IS reachable on the remote (recorded as a durable
440
+ // push-checkpoint via the harness's `worldMarker`) but left the PR head un-advanced. If that
441
+ // checkpoint SHA STRICTLY fast-forward-descends the current head, advance the head onto it and
442
+ // continue the loop as real progress instead of parking a human — auto-recovering the exact
443
+ // divergence that stranded nanobpm/nano-coder#26/#28. `currentHead` is non-null here (a null head
444
+ // fails OPEN upstream via decideProgress and never reaches this escalation). Every non-heal
445
+ // outcome (no checkpoint, not a fast-forward, a fork head, an unreadable/refused move) degrades
446
+ // to escalation exactly as before — the self-heal is purely additive.
447
+ if (currentHead) {
448
+ // FENCE THE REF MUTATION (Copilot review on #818 / #819). The self-heal PATCHes the PR head on
449
+ // GitHub — an irreversible side effect the atomic `commit` fence below can only guard for the
450
+ // DB write, NOT for a mutation already made. A straggler from a SUPERSEDED instance (its stale
451
+ // checkpoint would resurrect old work on a PR `submitPr` has since reopened under a new
452
+ // `process_key`) must not move the head. Two layers guard this: (1) the run-scoped checkpoint
453
+ // lookup — the self-heal reads `lastCheckpointForRun(prKey, jobProcessKey, roundNo)`, so a fresh
454
+ // run never even sees the prior run's checkpoint; and (2) a cheap early-out here PLUS a LATE
455
+ // ownership re-check threaded INTO the heal (`isSuperseded` inverted → `stillOwns`), evaluated
456
+ // immediately before the ref PATCH, closing the check-to-mutation TOCTOU where a concurrent
457
+ // `submitPr` supersedes this run DURING the heal's awaits. A skipped/abandoned heal falls
458
+ // through to the escalation `commit`, which the same fence turns into a harmless ack-without-write.
459
+ const jobProcessKey = job.processInstanceKey != null ? String(job.processInstanceKey) : null;
460
+ if (!(await isSuperseded())) {
461
+ const heal = await (deps.selfHeal ?? defaultSelfHeal)(
462
+ app,
463
+ ghRepo,
464
+ ghNumber,
465
+ prKey,
466
+ currentHead,
467
+ roundNo,
468
+ jobProcessKey,
469
+ async () => !(await isSuperseded()),
470
+ ).catch((): SelfHealResult => ({ healed: false }));
471
+ if (heal.healed && heal.sha) {
472
+ app.log.info("no-advance self-heal advanced PR head to a reachable push-checkpoint", {
473
+ prKey,
474
+ round: roundNo,
475
+ from: currentHead,
476
+ to: heal.sha,
477
+ });
478
+ // Advance the baseline to the newly-pushed head and park at wait-review as genuine progress
479
+ // (husk counter reset), so the poller solicits the next Copilot review against the healed head.
480
+ return commit(
481
+ { progressed: true, huskRetries: 0 },
482
+ { head: heal.sha, status: "waiting_review", agentWatermark },
483
+ );
484
+ }
485
+ }
486
+ }
487
+
488
+ // The remaining outcome (progressed:false, huskRetry:false, no self-heal) escalates to a human;
489
+ // the persist-escalation-noprogress worker owns the status, so leave it unset — but still advance
490
+ // the baseline and stamp the idempotency record so a redelivered escalation replays instead of
392
491
  // recomputing.
393
492
  return commit(out, { head: currentHead, agentWatermark });
394
493
  };