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/dist/do.d.ts.map +1 -1
- package/dist/do.js +71 -2
- package/dist/do.js.map +1 -1
- package/dist/item-lock.d.ts.map +1 -1
- package/dist/item-lock.js +28 -1
- package/dist/item-lock.js.map +1 -1
- package/dist/needs-attention.d.ts.map +1 -1
- package/dist/needs-attention.js +42 -1
- package/dist/needs-attention.js.map +1 -1
- package/dist/pi-harness.d.ts +5 -0
- package/dist/pi-harness.d.ts.map +1 -1
- package/dist/pi-harness.js +93 -2
- package/dist/pi-harness.js.map +1 -1
- package/dist/reap-agent-tree.d.ts.map +1 -1
- package/dist/reap-agent-tree.js +19 -3
- package/dist/reap-agent-tree.js.map +1 -1
- package/dist/tasking.d.ts.map +1 -1
- package/dist/tasking.js +52 -3
- package/dist/tasking.js.map +1 -1
- package/package.json +1 -1
- package/src/do.ts +88 -2
- package/src/item-lock.ts +29 -1
- package/src/needs-attention.ts +41 -1
- package/src/pi-harness.ts +102 -2
- package/src/reap-agent-tree.ts +19 -3
- package/src/tasking.ts +52 -3
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
|
-
|
|
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
|
/**
|
package/src/reap-agent-tree.ts
CHANGED
|
@@ -72,11 +72,27 @@ export interface ReapResult {
|
|
|
72
72
|
detail: string;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|