dorfl 0.13.0 → 0.13.2

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/src/pi-harness.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import {spawn, spawnSync} from 'node:child_process';
2
- import {existsSync, readFileSync} from 'node:fs';
2
+ import {existsSync, readFileSync, writeSync} from 'node:fs';
3
3
  import {
4
4
  NullHarness,
5
5
  pidAlive,
@@ -66,6 +66,97 @@ import type {HarnessAdapter} from './config.js';
66
66
  /** The default pi CLI binary name (resolved on `PATH`). */
67
67
  export const DEFAULT_PI_BIN = 'pi';
68
68
 
69
+ /**
70
+ * **A runner MUST NOT exit while an async launch is unsettled** (observation
71
+ * `deadline-reap-lets-node-exit-0-before-the-checkpoint-runs`).
72
+ *
73
+ * An `await` is not a handle: node keeps a process alive for referenced HANDLES
74
+ * (timers, sockets, child processes), and a suspended promise is none of those.
75
+ * {@link PiHarness.launchAsync} deliberately drops every handle it owns the
76
+ * moment pi exits (it destroys the stdio pipes and `unref`s the child so a
77
+ * leaked grandchild's inherited FDs cannot pin the loop), and on the deadline
78
+ * path it then keeps the promise pending across the process-group reap. In the
79
+ * field that combination let the event loop go EMPTY mid-reap: node exited
80
+ * normally with code 0, the suspended pipeline (checkpoint save, branch push,
81
+ * lock release, writer-sentinel release, job-record update) simply never ran,
82
+ * and the run reported SUCCESS while leaving the item locked and 90 minutes of
83
+ * agent work uncommitted.
84
+ *
85
+ * So the launch holds an explicit REFERENCED keep-alive for exactly as long as
86
+ * it is in flight, and an exit guard turns any remaining way of exiting mid-
87
+ * launch into a LOUD, non-zero failure instead of a silent success. The two are
88
+ * deliberately independent: the keep-alive prevents the known mechanism, the
89
+ * guard refuses to let any future variant of it be mistaken for a clean run.
90
+ */
91
+ let inFlightLaunches = 0;
92
+ /** The referenced handle that keeps the loop alive while launches are in flight. */
93
+ let inFlightKeepAlive: NodeJS.Timeout | undefined;
94
+ let inFlightExitGuardInstalled = false;
95
+
96
+ /**
97
+ * The keep-alive tick. Long, because it exists ONLY to be a referenced handle:
98
+ * it never does work, and it is cleared the moment the last launch settles.
99
+ */
100
+ const KEEPALIVE_TICK_MS = 60_000;
101
+
102
+ /** Report an exit that happened with a launch still in flight, LOUDLY. */
103
+ function installInFlightExitGuard(): void {
104
+ if (inFlightExitGuardInstalled) {
105
+ return;
106
+ }
107
+ inFlightExitGuardInstalled = true;
108
+ process.on('exit', (code) => {
109
+ if (inFlightLaunches === 0) {
110
+ return;
111
+ }
112
+ // `writeSync` on fd 2, NOT console.error: stderr to a pipe is asynchronous,
113
+ // and an `exit` listener is the last synchronous moment there is, so a
114
+ // buffered write would be dropped exactly when it matters most.
115
+ writeSync(
116
+ 2,
117
+ `>> INTERNAL ERROR: dorfl is exiting while ${inFlightLaunches} agent ` +
118
+ 'launch(es) are still in flight, so the run STOPPED between the agent ' +
119
+ 'and its outcome: nothing was committed, pushed, surfaced or released, ' +
120
+ 'and any item lock is still held. This is a dorfl defect, not a task ' +
121
+ 'failure. Recover with `dorfl requeue <slug>` (the work branch/worktree ' +
122
+ 'is kept) and please report it.\n',
123
+ );
124
+ if (code === 0) {
125
+ // NEVER report this as success: a caller (CI leg, driving loop, `run`
126
+ // tick) that only checks the status must see a failure here.
127
+ process.exitCode = 1;
128
+ }
129
+ });
130
+ }
131
+
132
+ /** Mark one async launch as started (holds the loop open + arms the guard). */
133
+ function launchStarted(): void {
134
+ inFlightLaunches += 1;
135
+ installInFlightExitGuard();
136
+ if (inFlightKeepAlive === undefined) {
137
+ // REFERENCED on purpose: this is the handle that keeps the runner alive
138
+ // across the window where it owns no other one.
139
+ inFlightKeepAlive = setInterval(() => {}, KEEPALIVE_TICK_MS);
140
+ }
141
+ }
142
+
143
+ /** Mark one async launch as settled (releases the keep-alive when the last one lands). */
144
+ function launchSettled(): void {
145
+ inFlightLaunches = Math.max(0, inFlightLaunches - 1);
146
+ if (inFlightLaunches === 0 && inFlightKeepAlive !== undefined) {
147
+ clearInterval(inFlightKeepAlive);
148
+ inFlightKeepAlive = undefined;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * How many async launches are currently unsettled. Exposed for the regression
154
+ * test that pins the keep-alive/guard invariant; not part of the harness seam.
155
+ */
156
+ export function inFlightLaunchCount(): number {
157
+ return inFlightLaunches;
158
+ }
159
+
69
160
  /**
70
161
  * The grace period between a deadline SIGTERM and the follow-up SIGKILL in
71
162
  * {@link PiHarness.launchAsync} (spec `graceful-pre-timeout-wip-checkpoint`).
@@ -233,7 +324,11 @@ export class PiHarness implements Harness {
233
324
  command: [this.piBin, ...args].join(' '),
234
325
  session: sessionFile,
235
326
  };
236
- return new Promise<LaunchResult>((resolve, reject) => {
327
+ // IN FLIGHT from here until the promise settles: hold the loop open and arm
328
+ // the exit guard, so the runner can never quietly disappear between the
329
+ // agent and its outcome (see the keep-alive block above).
330
+ launchStarted();
331
+ const launch = new Promise<LaunchResult>((resolve, reject) => {
237
332
  const child = spawn(this.piBin, args, {
238
333
  // Same as `launch`: spawn in the repo/worktree dir so the session
239
334
  // header `cwd` groups the dashboard correctly (invariant #3).
@@ -436,6 +531,11 @@ export class PiHarness implements Harness {
436
531
  }
437
532
  child.stdin?.end();
438
533
  });
534
+ // Release the keep-alive on BOTH outcomes (resolve AND reject) — a failed
535
+ // spawn must not pin the loop open for the rest of the process's life.
536
+ return launch.finally(() => {
537
+ launchSettled();
538
+ });
439
539
  }
440
540
 
441
541
  /**
@@ -72,11 +72,27 @@ export interface ReapResult {
72
72
  detail: string;
73
73
  }
74
74
 
75
- /** Sleep helper (injectable clock is not needed: callers inject `wait` in tests). */
75
+ /**
76
+ * Sleep helper (injectable clock is not needed: callers inject `wait` in tests).
77
+ *
78
+ * **The timer is deliberately REFERENCED — do not `unref()` it.** This sleep is
79
+ * the ONLY pending handle the runner holds while it waits for a signalled group
80
+ * to die: by the time {@link reapProcessGroup} runs, the harness has already
81
+ * destroyed the child's stdio and `unref`'d the child handle, and a pending
82
+ * promise is not a handle. An `unref`'d timer here therefore left the event loop
83
+ * with NOTHING referenced, so node did the correct thing with an empty loop and
84
+ * EXITED 0 mid-reap — abandoning the suspended `await` and with it the whole
85
+ * deadline checkpoint (no WIP commit, no branch push, no lock release, no
86
+ * sentinel release), while reporting success (observation
87
+ * `deadline-reap-lets-node-exit-0-before-the-checkpoint-runs`).
88
+ *
89
+ * Referencing it cannot hang a runner: this loop is bounded by construction
90
+ * (`sigtermGraceMs + sigkillTimeoutMs`), which is the same property that lets
91
+ * the launch resolve-on-`exit` discipline stay safe.
92
+ */
76
93
  function sleep(ms: number): Promise<void> {
77
94
  return new Promise((resolve) => {
78
- const timer = setTimeout(resolve, ms);
79
- timer.unref?.();
95
+ setTimeout(resolve, ms);
80
96
  });
81
97
  }
82
98
 
package/src/tasking.ts CHANGED
@@ -536,10 +536,59 @@ export async function performTask(
536
536
  }
537
537
  if (!agent.ok) {
538
538
  const detail = agent.detail ?? `the agent failed to task '${slug}'.`;
539
- const message = `Agent failed tasking '${slug}' (${detail}).`;
539
+ let message = `Agent failed tasking '${slug}' (${detail}).`;
540
+ // RELEASE THE LOCK THE FAILED RUN TOOK (observation
541
+ // `crashed-do-spec-strands-a-tasking-lock-no-verb-releases`).
542
+ //
543
+ // The pre-fix behaviour left the lock HELD on the arbiter, justified by a
544
+ // comment claiming "surfacing it is the review/edit loop's job". That is
545
+ // UNREACHABLE from here: the review/edit loop runs at step 3.5, strictly
546
+ // AFTER this early return, so an agent that dies at step 3 is surfaced by
547
+ // NOBODY. The lock outlived every process that knew about it and each retry
548
+ // of `do spec:<slug>` lost the create-only CAS against a holder that no
549
+ // longer existed (`'spec-<slug>' is already locked (held by another)`).
550
+ // Field trigger: three consecutive model-API faults (`Connection error.`,
551
+ // `overloaded_error`, `api_error`).
552
+ //
553
+ // WHY A PLAIN RELEASE AND NOT A `stuck` SURFACE. The sibling failure just
554
+ // below (`ReviewParseError`) routes through `surfaceTaskingBlock`, which
555
+ // marks the spec `needsAnswers:true` + writes a question sidecar. That is
556
+ // correct THERE (a review verdict is a JUDGEMENT a human must resolve) and
557
+ // wrong HERE: an agent/transport crash carries no judgement to record, and
558
+ // surfacing it would take the spec OUT of the taskable pool behind a
559
+ // contentless question — turning a retryable blip into mandatory human
560
+ // paperwork. A crashed agent is RETRYABLE, so the recovery is to return the
561
+ // spec to the pool and let `do spec:<slug>` simply be re-run.
562
+ //
563
+ // WHY THIS DISCARDS NOTHING. The tasking work branch is created by
564
+ // `switchToWorkBranch` with a LOCAL `git switch -C` and is never pushed
565
+ // before the integrate band; the durable `specs/ready → specs/tasked` move
566
+ // also happens only at integrate. A run that died at step 3 therefore
567
+ // published NOTHING to the arbiter, so releasing its lock cannot lose work.
568
+ // The release is deliberately no more cautious than that risk warrants.
569
+ //
570
+ // A release FAULT is reported but never masks the agent failure: the agent
571
+ // outcome is the terminal one, and `release-lock spec:<slug>` remains the
572
+ // human backstop for the case where this process is itself killed.
573
+ if (useLock) {
574
+ const released = await lock.release({
575
+ slug,
576
+ cwd,
577
+ arbiter,
578
+ lockedBlob,
579
+ env,
580
+ note,
581
+ });
582
+ message =
583
+ released.exitCode === 0
584
+ ? `${message} Released the tasking lock for '${slug}' — the spec is ` +
585
+ 'back in the taskable pool, so re-run `dorfl do spec:' +
586
+ `${slug}\` to retry (nothing was published, so nothing was lost).`
587
+ : `${message} WARNING: could not release the tasking lock for ` +
588
+ `'${slug}' (${released.message}). Clear it with \`dorfl ` +
589
+ `release-lock spec:${slug}\` before retrying.`;
590
+ }
540
591
  note(message);
541
- // The lock stays held (the runner did not release it): a stuck tasking is
542
- // recoverable / re-runnable. Surfacing it is the review/edit loop's job.
543
592
  return {exitCode: 1, outcome: 'agent-failed', slug, message};
544
593
  }
545
594