dorfl 0.12.0 → 0.13.1

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 (46) hide show
  1. package/dist/advance.d.ts +8 -1
  2. package/dist/advance.d.ts.map +1 -1
  3. package/dist/advance.js +39 -6
  4. package/dist/advance.js.map +1 -1
  5. package/dist/apply-decide.d.ts +7 -1
  6. package/dist/apply-decide.d.ts.map +1 -1
  7. package/dist/apply-decide.js +23 -10
  8. package/dist/apply-decide.js.map +1 -1
  9. package/dist/apply-persist.d.ts +52 -8
  10. package/dist/apply-persist.d.ts.map +1 -1
  11. package/dist/apply-persist.js +78 -9
  12. package/dist/apply-persist.js.map +1 -1
  13. package/dist/do.d.ts.map +1 -1
  14. package/dist/do.js +71 -2
  15. package/dist/do.js.map +1 -1
  16. package/dist/frontmatter.d.ts +17 -10
  17. package/dist/frontmatter.d.ts.map +1 -1
  18. package/dist/frontmatter.js +3 -3
  19. package/dist/frontmatter.js.map +1 -1
  20. package/dist/pi-harness.d.ts +5 -0
  21. package/dist/pi-harness.d.ts.map +1 -1
  22. package/dist/pi-harness.js +93 -2
  23. package/dist/pi-harness.js.map +1 -1
  24. package/dist/protocol/WORK-CONTRACT.md +2 -1
  25. package/dist/reap-agent-tree.d.ts.map +1 -1
  26. package/dist/reap-agent-tree.js +19 -3
  27. package/dist/reap-agent-tree.js.map +1 -1
  28. package/dist/skills/setup/protocol/WORK-CONTRACT.md +2 -1
  29. package/dist/skills/triage-observations/SKILL.md +1 -1
  30. package/dist/triage-gate.d.ts +4 -2
  31. package/dist/triage-gate.d.ts.map +1 -1
  32. package/dist/triage-gate.js.map +1 -1
  33. package/dist/triage-persist.d.ts +55 -5
  34. package/dist/triage-persist.d.ts.map +1 -1
  35. package/dist/triage-persist.js +68 -7
  36. package/dist/triage-persist.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/advance.ts +52 -4
  39. package/src/apply-decide.ts +23 -10
  40. package/src/apply-persist.ts +86 -9
  41. package/src/do.ts +88 -2
  42. package/src/frontmatter.ts +17 -10
  43. package/src/pi-harness.ts +102 -2
  44. package/src/reap-agent-tree.ts +19 -3
  45. package/src/triage-gate.ts +4 -2
  46. package/src/triage-persist.ts +110 -9
package/src/advance.ts CHANGED
@@ -32,10 +32,13 @@ import type {ObservationTriage} from './config.js';
32
32
  import {
33
33
  autoDispositionObservation,
34
34
  promoteObservation,
35
+ stampTriagedMarker,
35
36
  type AutoDispositionOptions,
36
37
  type AutoDispositionResult,
37
38
  type PromoteObservationOptions,
38
39
  type PromoteObservationResult,
40
+ type StampTriagedMarkerOptions,
41
+ type StampTriagedMarkerResult,
39
42
  } from './triage-persist.js';
40
43
  import {mintAdr, type MintAdrOptions, type MintAdrResult} from './mint-adr.js';
41
44
  import {LIFECYCLE_CAS_CONTENTION} from './advancing-lock.js';
@@ -57,6 +60,8 @@ import {
57
60
  } from './surface-persist.js';
58
61
  import {
59
62
  applyAnsweredQuestions,
63
+ hasAppliedAnswersRecord,
64
+ TRIAGED_RESOLVE,
60
65
  type ApplyAnsweredQuestionsOptions,
61
66
  type ApplyAnsweredQuestionsResult,
62
67
  } from './apply-persist.js';
@@ -324,6 +329,15 @@ export interface AdvanceContext {
324
329
  * commit). Tests inject a spy; production uses {@link autoDispositionObservation}.
325
330
  */
326
331
  autoDisposition?: (options: AutoDispositionOptions) => AutoDispositionResult;
332
+ /**
333
+ * Back-stamp the `triaged:` settled marker on a note that was ALREADY
334
+ * resolved-and-kept before the apply rung learned to stamp it (the legacy arm of
335
+ * the triage rung's settled guard). Tests inject a spy; production uses
336
+ * {@link stampTriagedMarker}.
337
+ */
338
+ stampTriaged?: (
339
+ options: StampTriagedMarkerOptions,
340
+ ) => StampTriagedMarkerResult;
327
341
  /**
328
342
  * Promote an answered observation: CAS-create a new backlog stub keyed on the
329
343
  * NEW item's identity, then record + resolve the observation. Tests inject a
@@ -910,10 +924,20 @@ async function triageRung(input: RungExecInput): Promise<RungExecResult> {
910
924
  // note. (Replaces the old `detectObservationLimbo` special-case: there is no
911
925
  // limbo any more — an UNtriaged observation always surfaces the deterministic
912
926
  // question below; a SETTLED one is a calm no-op here.)
927
+ //
928
+ // SECOND ARM — the LEGACY back-fill (ADR
929
+ // `resolve-settles-the-question-loop-not-the-note`): a note the apply rung
930
+ // resolved-and-KEPT before it learned to stamp the marker carries the
931
+ // ENGINE-WRITTEN `## Applied answers` record but NO marker, which is the exact
932
+ // shape this rung would re-ask forever. That record is PROOF the engine already
933
+ // applied a human's answers to this note, so we stamp what the apply rung would
934
+ // have written and no-op. Self-healing and one-shot: the stamped note drops out
935
+ // of the pool, so this arm can never fire twice for the same note.
913
936
  {
914
937
  const itemRel = findItemPath(cwd, input.namespace, input.slug);
915
938
  if (itemRel !== undefined) {
916
- const fm = parseFrontmatter(readFileSync(join(cwd, itemRel), 'utf8'));
939
+ const body = readFileSync(join(cwd, itemRel), 'utf8');
940
+ const fm = parseFrontmatter(body);
917
941
  if (fm.triaged !== undefined && fm.triaged !== '') {
918
942
  return {
919
943
  exitCode: 0,
@@ -921,6 +945,21 @@ async function triageRung(input: RungExecInput): Promise<RungExecResult> {
921
945
  message: `triage ${item}: already triaged (triaged:${fm.triaged}) — nothing to do.`,
922
946
  };
923
947
  }
948
+ if (hasAppliedAnswersRecord(body)) {
949
+ const stamp = context.stampTriaged ?? stampTriagedMarker;
950
+ const result = stamp({
951
+ cwd,
952
+ item,
953
+ itemPath: itemRel,
954
+ value: TRIAGED_RESOLVE,
955
+ reason:
956
+ 'Back-filled: the note carries an engine-written applied-answers ' +
957
+ 'record, so its triage question-loop was already settled by a human ' +
958
+ 'answer before the apply rung stamped the marker.',
959
+ note,
960
+ });
961
+ return {exitCode: 0, outcome: 'no-op', message: result.message};
962
+ }
924
963
  }
925
964
  }
926
965
 
@@ -951,9 +990,10 @@ async function triageRung(input: RungExecInput): Promise<RungExecResult> {
951
990
  if (decision.auto === true) {
952
991
  // A no-question case (duplicate / map) — auto-disposition WITHOUT a
953
992
  // question. BOTH discharge the redundant note BY DELETION (a duplicate is a
954
- // redundant copy; a map is already covered by the item it maps onto). There
955
- // is no resting `triaged:keep` state any more. NEVER auto-deletes a
956
- // NON-redundant signal; NEVER auto-promotes.
993
+ // redundant copy; a map is already covered by the item it maps onto), so an
994
+ // AUTO disposition never RESTS a note — resting a KEPT note is the
995
+ // human-answered `resolve` verdict's job on the apply rung. NEVER auto-deletes
996
+ // a NON-redundant signal; NEVER auto-promotes.
957
997
  const dispose = context.autoDisposition ?? autoDispositionObservation;
958
998
  const result = dispose({
959
999
  cwd,
@@ -1654,6 +1694,14 @@ async function applyAgenticDecision(
1654
1694
  // the sibling of `dispose`, which git-rm's it or moves it to a terminal). `resolveReason` is advisory
1655
1695
  // context only; the durable disposition record is the harvested `## Applied
1656
1696
  // answers` block the resolve-fully path writes (NOT a separate convention).
1697
+ //
1698
+ // Because the note is KEPT, that same commit also stamps `triaged: resolve` on
1699
+ // it (ADR `resolve-settles-the-question-loop-not-the-note`): a kept note stays
1700
+ // in the inbox the triage rung scans, and without the marker its two
1701
+ // classifier signals (`needsAnswers:false`, no sidecar) are IDENTICAL to a note
1702
+ // that was never triaged — so the next tick re-surfaced the same engine-built
1703
+ // triage question forever. The stamp is done by the persist, not here, so no
1704
+ // caller of the resolve-fully path can forget it.
1657
1705
  const apply = context.applyPersist ?? applyAnsweredQuestions;
1658
1706
  try {
1659
1707
  const result = apply({cwd, item, itemPath, note});
@@ -40,7 +40,13 @@ import {
40
40
  * by the task `apply-decide-resolve-verdict-mint-nothing` so the decider can
41
41
  * honestly handle "the human answered, keep the note on record, mint nothing"
42
42
  * (previously it had no valid verdict for that case and looped on `ask`,
43
- * re-surfacing an already-answered question every tick). `adr` was DEFERRED at the
43
+ * re-surfacing an already-answered question every tick). Its MEANING was SHARPENED
44
+ * by ADR `resolve-settles-the-question-loop-not-the-note`: `resolve` settles the
45
+ * QUESTION-LOOP on a note that REMAINS a live signal (and the persist stamps
46
+ * `triaged:` so the triage rung stops re-asking it, closing a second re-ask loop
47
+ * the first fix left open); a note whose SIGNAL is finished is `dispose`, not
48
+ * `resolve` — a dead note kept and stamped "resolved" is exactly the
49
+ * backward-artifact-in-a-forward-bucket the work contract forbids. `adr` was DEFERRED at the
44
50
  * keystone launch (no
45
51
  * ADR-mint path existed yet) and is now WIRED by the follow-on task
46
52
  * `agentic-apply-mint-adr-route`, which added the {@link
@@ -201,15 +207,22 @@ export function buildApplyDecisionPrompt(input: ApplyDecisionInput): string {
201
207
  ` \`reason:\` frontmatter); a SPEC is git-mv-ed to \`specs/dropped/\` (RETAINED).`,
202
208
  ` A task can NEVER be hard-deleted by the apply rung \u2014 dispose is the`,
203
209
  ` only path off the board.`,
204
- ` - "resolve": the answer SETTLES this item and there is NOTHING to mint (no`,
205
- ` task/spec/adr) \u2014 the correct move is to CLOSE the question-loop while`,
206
- ` KEEPING the note on record (e.g. an evidence/watch-item observation whose`,
207
- ` answer is "acknowledged, keep this on record, no artifact"). The answers are`,
208
- ` harvested into the item body and the loop is cleared; the note is RETAINED`,
209
- ` (this is the sibling of "dispose", which DROPS the observation-note or`,
210
- ` moves a task/spec to its terminal \u2014 pick "resolve" when the observation`,
211
- ` should SURVIVE in place, "dispose" when it should not). Emit`,
212
- ` {"outcome":"resolve","resolveReason":"\u2026"}.`,
210
+ ` - "resolve": the answer SETTLES the QUESTION-LOOP and there is NOTHING to`,
211
+ ` mint (no task/spec/adr), AND the note is STILL A LIVE SIGNAL worth keeping`,
212
+ ` in the inbox \u2014 e.g. a standing map of a known gap, or accepted residue the`,
213
+ ` answer explicitly says to keep on record until it is acted on. The answers`,
214
+ ` are harvested into the item body, the loop is cleared, and the note is`,
215
+ ` RETAINED and STAMPED as triaged, so it is never re-asked this question.`,
216
+ ` Emit {"outcome":"resolve","resolveReason":"\u2026"}.`,
217
+ ` PICK BETWEEN "resolve" AND "dispose" ON LIVENESS, NOT ON POLITENESS: both`,
218
+ ` close the loop and mint nothing, and the ONLY question is whether the note`,
219
+ ` is still a live signal AFTER this answer. If the answer means the signal is`,
220
+ ` FINISHED (the thing was fixed, it was already covered elsewhere, it is`,
221
+ ` obsolete, or its whole content is now carried by a task/spec/ADR/commit),`,
222
+ ` that note stops being live \u2014 emit "dispose", NOT "resolve". "resolve" is`,
223
+ ` only correct when the note still carries a signal nothing else records.`,
224
+ ` Do NOT use "resolve" as a soft "dispose": a note kept only to show it was`,
225
+ ` handled is a backward artifact in a forward bucket.`,
213
226
  ` - "ask": you need more from the human before acting. Emit`,
214
227
  ` {"outcome":"ask","question":"\u2026"} \u2014 ask everything you still need as ONE`,
215
228
  ` batch (never a drip); the engine appends it and re-pauses.`,
@@ -42,17 +42,26 @@ import {workItemRel} from './work-layout.js';
42
42
  * hard-deleted from here — dispose is the only path off the board; OR
43
43
  * - **resolve fully** (the default) — clear `needsAnswers` + DELETE the sidecar
44
44
  * in the SAME atomic commit (the invariant `needsAnswers:false ⟺ no active
45
- * sidecar`); the item advances toward build by its normal lifecycle.
45
+ * sidecar`); the item advances toward build by its normal lifecycle. For an
46
+ * OBSERVATION this ALSO stamps the `triaged:` settled marker (see
47
+ * {@link TRIAGED_RESOLVE}) in that same commit — the note is KEPT, so it needs
48
+ * a durable record that its triage question-loop is CLOSED or the triage rung
49
+ * re-asks it forever (ADR `resolve-settles-the-question-loop-not-the-note`).
46
50
  *
47
51
  * **The disposition vocabulary is GONE** (task
48
52
  * `agentic-apply-retire-disposition-vocabulary`): the apply rung no longer reads a
49
- * per-entry `disposition=` token, no longer runs a most-decisive picker, and has
50
- * no `keep`/`triaged:keep` resting state. A sidecar entry is BINARY (no-answer |
51
- * answered); what to DO with a fully-answered OBSERVATION is decided by the
52
- * AGENTIC apply decision in the advance tick (`advance.ts` `applyRung` over the
53
- * shared `decide` engine), which then routes here (re-pause / dispose / via
54
- * `promoteObservation` for a mint). A signal is still-open, acted-on, or deleted
55
- * — there is no \"retain as resolved\" state.
53
+ * per-entry `disposition=` token and no longer runs a most-decisive picker. A
54
+ * sidecar entry is BINARY (no-answer | answered); what to DO with a fully-answered
55
+ * OBSERVATION is decided by the AGENTIC apply decision in the advance tick
56
+ * (`advance.ts` `applyRung` over the shared `decide` engine), which then routes
57
+ * here (re-pause / dispose / resolve / via `promoteObservation` for a mint).
58
+ *
59
+ * What the retirement removed was the human-stamped `disposition=`/`promote-*`
60
+ * TOKEN vocabulary, NOT the settled AXIS: a note that the `resolve` verdict KEEPS
61
+ * still rests as `triaged: resolve` + `needsAnswers:false` + no sidecar. That is
62
+ * the one resting state a note has, and it says "the question-loop is settled",
63
+ * NOT "the signal is finished" (a finished signal is `dispose`d — it leaves the
64
+ * inbox by deletion).
56
65
  *
57
66
  * The work-item (task/spec) terminal MOVES (`tasks/cancelled`, `specs/dropped`) and
58
67
  * the stuck (lock `state: stuck`) LIFECYCLE state are a SEPARATE lifecycle concern (a
@@ -87,6 +96,32 @@ export {APPLY_LIFECYCLE_FOLDERS, resolveItemPathByIdentity};
87
96
  /** Marker that opens the applied-answers record in an item body. */
88
97
  const APPLIED_HEADING = '## Applied answers';
89
98
 
99
+ /**
100
+ * The `triaged:` settled-marker VALUE the resolve-fully path stamps on a KEPT
101
+ * OBSERVATION (ADR `resolve-settles-the-question-loop-not-the-note`).
102
+ *
103
+ * The `resolve` verdict is the ONE apply terminal that RETAINS an observation in
104
+ * the inbox. Every other terminal removes it from the triage pool by MOVING or
105
+ * DELETING the file (mint → `git rm` + the new artifact; dispose → `git rm` /
106
+ * terminal folder), so only this one needs an in-file record that the note has
107
+ * been through triage. Without it the resolved note is byte-indistinguishable, to
108
+ * the classifier's two signals (`needsAnswers` + sidecar), from a note that was
109
+ * NEVER triaged: `needsAnswers:false` + no sidecar ⇒ ANALYSE ⇒ `triage-observation`
110
+ * ⇒ the engine-built triage question is surfaced AGAIN, and the human is asked
111
+ * something they already answered (observed live in the `rocketh` consumer repo:
112
+ * surface → answer → resolve → the byte-identical question re-surfaced).
113
+ *
114
+ * The marker is the axis the READ side already models end-to-end — `frontmatter.ts`
115
+ * parses it, `ledger-read.ts` carries it, `lifecycle-pools.ts` drops a marked
116
+ * observation out of the CREATE-side triage pool, and `advance.ts`'s triage rung
117
+ * no-ops on it for an explicit `obs:<slug>`. Only the WRITER was missing.
118
+ *
119
+ * It does NOT strand a later answer: an ANSWERED sidecar dominates the marker (ADR
120
+ * `answered-observation-sidecar-dominates-triaged-marker`), so a genuinely NEW
121
+ * question about a settled note still routes to apply and is still answerable.
122
+ */
123
+ export const TRIAGED_RESOLVE = 'resolve';
124
+
90
125
  /**
91
126
  * The STRUCTURAL fence the templates wrap the transient open-questions block in
92
127
  * — an HTML-comment marker pair, mirroring the existing `<!-- dorfl-…
@@ -108,6 +143,21 @@ const APPLIED_HEADING = '## Applied answers';
108
143
  export const OPEN_QUESTIONS_MARKER_OPEN = '<!-- open-questions -->';
109
144
  export const OPEN_QUESTIONS_MARKER_CLOSE = '<!-- /open-questions -->';
110
145
 
146
+ /**
147
+ * Does this item body carry the ENGINE-WRITTEN applied-answers record?
148
+ *
149
+ * Only {@link applyAnsweredQuestions} writes this heading, and only after a human
150
+ * answered EVERY open question on the item, so its presence is PROOF the engine
151
+ * has applied a human's answers here — not a prose heuristic. The triage rung uses
152
+ * it as the legacy back-fill trigger for notes resolved-and-kept before the
153
+ * `triaged:` stamp existed (ADR `resolve-settles-the-question-loop-not-the-note`).
154
+ */
155
+ export function hasAppliedAnswersRecord(body: string): boolean {
156
+ return new RegExp(`^${APPLIED_HEADING}\\b`, 'm').test(
157
+ body.replace(/\r\n/g, '\n'),
158
+ );
159
+ }
160
+
111
161
  export interface ApplyAnsweredQuestionsOptions {
112
162
  /** Working clone/worktree the apply commits in. */
113
163
  cwd: string;
@@ -490,7 +540,10 @@ export function applyAnsweredQuestions(
490
540
  // compatible — no marker ⇒ identical bytes. The re-pause path above is
491
541
  // deliberately untouched (D3): its open-questions block is still open.
492
542
  const reconciledBody = stripOpenQuestionsBlocks(baseBody);
493
- const resolvedBody = withAppliedAnswers(reconciledBody, model.entries);
543
+ const resolvedBody = withTriagedMarkerIfKeptNote(
544
+ withAppliedAnswers(reconciledBody, model.entries),
545
+ item,
546
+ );
494
547
  const result = applyAtomic({
495
548
  cwd,
496
549
  itemPath,
@@ -512,6 +565,30 @@ export function applyAnsweredQuestions(
512
565
  };
513
566
  }
514
567
 
568
+ /**
569
+ * Stamp the `triaged:` settled marker on a resolve-fully body — but ONLY for an
570
+ * OBSERVATION (the one item type this path KEEPS in a re-scanned inbox).
571
+ *
572
+ * Structural, not caller-supplied, so it cannot be forgotten by a future caller:
573
+ * every route that reaches the resolve-fully branch with an observation identity
574
+ * has, by definition, just closed that note's question-loop while retaining the
575
+ * file. A TASK/SPEC is deliberately EXCLUDED — it rests in a lifecycle FOLDER (its
576
+ * status), is not enumerated from the observation inbox, and has no triage rung to
577
+ * re-ask it; stamping a triage marker on one would invent a second status axis for
578
+ * an item whose status is already the folder.
579
+ *
580
+ * Idempotent ({@link setFrontmatterMarker} replaces an existing key) and
581
+ * fence-creating (observations are routinely born fence-less), so re-resolving a
582
+ * note after a genuinely new question re-stamps rather than duplicating.
583
+ */
584
+ function withTriagedMarkerIfKeptNote(body: string, item: string): string {
585
+ const {type} = resolveSidecarIdentity(item);
586
+ if (type !== 'observation') {
587
+ return body;
588
+ }
589
+ return setFrontmatterMarker(body, 'triaged', TRIAGED_RESOLVE);
590
+ }
591
+
515
592
  interface DisposeInput {
516
593
  cwd: string;
517
594
  item: string;
package/src/do.ts CHANGED
@@ -17,7 +17,12 @@ import {
17
17
  resolvePromptGuidanceForItem,
18
18
  PromptError,
19
19
  } from './prompt.js';
20
- import {NullHarness, type AgentTreeReap, type Harness} from './harness.js';
20
+ import {
21
+ NullHarness,
22
+ type AgentTreeReap,
23
+ type Harness,
24
+ type HarnessRecord,
25
+ } from './harness.js';
21
26
  import {acquireWorktreeWriterLock} from './worktree-writer-lock.js';
22
27
  import {PiHarness} from './pi-harness.js';
23
28
  import {launchWithOptionalWatch} from './agent-launch.js';
@@ -33,7 +38,7 @@ import {
33
38
  type IsolatedTree,
34
39
  } from './isolation.js';
35
40
  import {ensureMirror, encodeRepoKey, mirrorPath} from './repo-mirror.js';
36
- import {jobWorktreePath} from './workspace.js';
41
+ import {jobWorktreePath, updateJobRecord} from './workspace.js';
37
42
  import {reapJob} from './gc.js';
38
43
  import {isGitHubArbiterUrl, GitHubProvider} from './github.js';
39
44
  import type {ReviewProvider} from './integrator.js';
@@ -1983,6 +1988,15 @@ async function runDoAgent(
1983
1988
  output?: string;
1984
1989
  timedOut?: boolean;
1985
1990
  reap?: AgentTreeReap;
1991
+ /**
1992
+ * The launch's real harness record (adapter + liveness anchor), when the run
1993
+ * went through a harness. The caller threads it into the job record so the
1994
+ * record says what ACTUALLY ran, not `createJob`'s placeholder
1995
+ * (`{adapter: 'null'}`) — the placeholder is what stranded a field
1996
+ * investigation into reading a healthy pi run as a null-harness misfire
1997
+ * (observation `deadline-reap-lets-node-exit-0-before-the-checkpoint-runs`).
1998
+ */
1999
+ record?: HarnessRecord;
1986
2000
  }> {
1987
2001
  if (options.dorfl) {
1988
2002
  return options.dorfl({cwd, prompt, slug, env: options.env});
@@ -2032,6 +2046,7 @@ async function launchAgentUnderWriterLock(params: {
2032
2046
  output?: string;
2033
2047
  timedOut?: boolean;
2034
2048
  reap?: AgentTreeReap;
2049
+ record?: HarnessRecord;
2035
2050
  }> {
2036
2051
  const {options, cwd, prompt, slug, harness} = params;
2037
2052
  // Convert the dorfl-internal deadline (minutes) into a wall-clock epoch-ms so
@@ -2068,6 +2083,10 @@ async function launchAgentUnderWriterLock(params: {
2068
2083
  detail: launched.detail,
2069
2084
  output: launched.output,
2070
2085
  timedOut: launched.timedOut,
2086
+ // The launch's REAL record (adapter + pid/session anchor) — the caller
2087
+ // finalises the job record with it (mirroring `run.ts`), so a `do` job's
2088
+ // record stops reading `{adapter: 'null'}` forever.
2089
+ record: launched.record,
2071
2090
  // The harness's PROOF that a deadline-stopped agent's process tree is gone.
2072
2091
  // Threaded to {@link routeDeadlineCheckpoint}, which refuses to release the
2073
2092
  // item lock without it.
@@ -2610,6 +2629,12 @@ export async function performDoRemote(
2610
2629
  throw err;
2611
2630
  }
2612
2631
  result = await runRemotePipeline(options, tree, slug, note, env);
2632
+ // Finalise the job record to MATCH the terminal outcome (mirroring `run`'s
2633
+ // tail): a retained worktree whose record still says `state: running`
2634
+ // reads as a crashed job to `status`/`gc` FOREVER, when the run in fact
2635
+ // reached a decision (observation
2636
+ // `deadline-reap-lets-node-exit-0-before-the-checkpoint-runs`).
2637
+ finaliseDoJobRecord(tree.dir, result);
2613
2638
  return result;
2614
2639
  } finally {
2615
2640
  // 7. Teardown via the strategy handle. On a CLEAN completion: reap iff clean
@@ -2632,6 +2657,53 @@ export async function performDoRemote(
2632
2657
  }
2633
2658
  }
2634
2659
 
2660
+ /**
2661
+ * Bring a `do --remote`/`--isolated` job record in line with the terminal outcome
2662
+ * the pipeline is returning — the SAME tail discipline `run` applies
2663
+ * (`updateJobRecord(tree.dir, …)` on every routed outcome), which the `do` path
2664
+ * never had (
2665
+ * `deadline-reap-lets-node-exit-0-before-the-checkpoint-runs`): until now
2666
+ * `createJob`'s initial `state: 'running'` record stood FOREVER on a retained
2667
+ * worktree, so `status`/`gc` could not tell a decided-but-retained job from a
2668
+ * runner that died mid-flight.
2669
+ *
2670
+ * The mapping is by outcome family, mirroring `run`'s tail:
2671
+ *
2672
+ * - `completed`: state `done` (usually moot — the §4 teardown reaps the
2673
+ * worktree and its record immediately; kept for parity and for the retained
2674
+ * edge).
2675
+ * - `deadline-auto-continued`: deliberately UNTOUCHED. Nothing needs
2676
+ * attention — the checkpoint saved the WIP, pushed the branch, and released
2677
+ * the lock so the next claim continues the job. The record's `running` here
2678
+ * means "the job is not over", which is true.
2679
+ * - everything else that reached a terminal decision (needs-attention family,
2680
+ * the failure-cause axis, agent-stopped, refusals/usage errors): state
2681
+ * `needs-attention` with the pipeline's own message as the reason, so
2682
+ * `status` surfaces WHY without re-deriving it.
2683
+ *
2684
+ * Outcomes that never claimed the item (`lost`/`contended`) return before a
2685
+ * worktree exists, so they never reach this helper. A pipeline that THREW
2686
+ * likewise skips it — the failure is loud (the CLI crashes non-zero), and the
2687
+ * retained record now carries the real harness anchor (finalised at launch),
2688
+ * so `status` reads it as crashed-running-but-dead, which is honest.
2689
+ */
2690
+ function finaliseDoJobRecord(dir: string, result: DoResult): void {
2691
+ switch (result.outcome) {
2692
+ case 'completed':
2693
+ updateJobRecord(dir, {state: 'done'});
2694
+ return;
2695
+ case 'deadline-auto-continued':
2696
+ case 'lost':
2697
+ case 'contended':
2698
+ return;
2699
+ default:
2700
+ updateJobRecord(dir, {
2701
+ state: 'needs-attention',
2702
+ reason: result.message,
2703
+ });
2704
+ }
2705
+ }
2706
+
2635
2707
  /**
2636
2708
  * Best-effort reap of a worktree that {@link IsolationStrategy.prepare}/`createJob`
2637
2709
  * may have created at the deterministic per-job path BEFORE it threw (so the
@@ -2882,6 +2954,7 @@ async function runRemotePipeline(
2882
2954
  output?: string;
2883
2955
  timedOut?: boolean;
2884
2956
  reap?: AgentTreeReap;
2957
+ record?: HarnessRecord;
2885
2958
  };
2886
2959
  try {
2887
2960
  agent = await runDoAgent(options, cwd, prompt, slug);
@@ -2897,6 +2970,19 @@ async function runRemotePipeline(
2897
2970
  note,
2898
2971
  });
2899
2972
  }
2973
+ // FINALISE the job record's harness block with the REAL launch record the
2974
+ // agent ran under (mirroring `run.ts`'s `updateJobRecord(tree.dir,
2975
+ // {harness: launched.record})`): `createJob` wrote the placeholder
2976
+ // `{adapter: 'null'}`, and nothing on the `do` path ever overwrote it, so
2977
+ // every `do` job's record read as adapter-null forever — on BOTH healthy and
2978
+ // failed runs (observation
2979
+ // `deadline-reap-lets-node-exit-0-before-the-checkpoint-runs`; a field
2980
+ // investigation read it as "no agent was attached" and chased the wrong
2981
+ // defect). The pid/session anchor is also what `status`/`gc` need to answer
2982
+ // liveness for a do-path job AT ALL.
2983
+ if (agent.record !== undefined) {
2984
+ updateJobRecord(cwd, {harness: agent.record});
2985
+ }
2900
2986
  // Deadline checkpoint on the no-checkout `do --remote` path — same routing
2901
2987
  // as the in-place path (see performDo).
2902
2988
  if (agent.timedOut) {
@@ -94,13 +94,20 @@ export interface Frontmatter {
94
94
  */
95
95
  needsAnswers: boolean | undefined;
96
96
  /**
97
- * The triage SETTLED marker (US #30). A non-empty `triaged:` value (e.g.
98
- * `keep` / `duplicate`) means a human (or the conservative auto-disposition)
99
- * has SETTLED this observation, so it DROPS OUT of the triage candidate pool
100
- * and is never re-asked. `undefined` when omitted (an UNTRIAGED observation,
101
- * still in the pool). Carried so the lifecycle-pool enumeration
102
- * (`advance-autopick-lifecycle-pools`) can exclude settled observations from
103
- * the triage selection.
97
+ * The triage SETTLED marker (US #30). A non-empty `triaged:` value means this
98
+ * observation's triage QUESTION-LOOP has been settled by a human's answer, so it
99
+ * DROPS OUT of the triage candidate pool and is never re-asked. `undefined` when
100
+ * omitted (an UNTRIAGED observation, still in the pool). Carried so the
101
+ * lifecycle-pool enumeration (`advance-autopick-lifecycle-pools`) can exclude
102
+ * settled observations from the triage selection.
103
+ *
104
+ * The one WRITER today is the apply rung's `resolve` verdict, which stamps
105
+ * `triaged: resolve` on a note it settles and KEEPS (ADR
106
+ * `resolve-settles-the-question-loop-not-the-note`). It says the question-loop is
107
+ * settled, NOT that the signal is finished — a finished signal has no resting
108
+ * state at all (it is deleted), and an ANSWERED sidecar still dominates this
109
+ * marker (ADR `answered-observation-sidecar-dominates-triaged-marker`), so a
110
+ * genuinely new question about a settled note is still asked and answered.
104
111
  */
105
112
  triaged: string | undefined;
106
113
  /** Slugs this item is blocked by; `[]` when omitted or empty. */
@@ -241,9 +248,9 @@ export function setNeedsAnswersMarker(content: string, value: boolean): string {
241
248
  /**
242
249
  * Set (or replace) a top-level scalar `<key>: <value>` frontmatter marker on a
243
250
  * `work/` markdown document, returning the new content. The generalised form of
244
- * {@link setNeedsAnswersMarker} the advance APPLY rung uses to stamp a
245
- * `triaged: keep` marker (US #30) on an item a human answered "keep" — so a
246
- * settled observation drops out of the candidate pool and is never re-asked. If a
251
+ * {@link setNeedsAnswersMarker} the advance APPLY rung uses to stamp the
252
+ * `triaged: resolve` marker (US #30) on a note a human's answer settles but KEEPS
253
+ * — so a settled observation drops out of the candidate pool and is never re-asked. If a
247
254
  * `<key>:` line already exists it is REPLACED (idempotent); otherwise it is
248
255
  * appended as the last line inside the frontmatter fence.
249
256
  *
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
 
@@ -26,8 +26,10 @@ import {extractJsonObjectSpan} from './verdict-json.js';
26
26
  * - `map` — an UNAMBIGUOUS map onto an existing item → DISCHARGE the redundant
27
27
  * note BY DELETION too (it is already covered by the item it maps onto, so it
28
28
  * carries no unique signal; the mapping is recorded in the commit message).
29
- * There is no resting `triaged:keep` state any more (task
30
- * `agentic-apply-retire-disposition-vocabulary`).
29
+ * An AUTO disposition never RESTS a note (task
30
+ * `agentic-apply-retire-disposition-vocabulary`); the one path that KEEPS a
31
+ * note is the human-answered `resolve` verdict on the apply rung (ADR
32
+ * `resolve-settles-the-question-loop-not-the-note`).
31
33
  *
32
34
  * It is the DIRECT mirror of `surface-gate.ts`'s spawn→emit→parse seam, with a
33
35
  * narrower emitted payload (`{auto, …}` rather than `{questions}`). Production