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.
- package/dist/advance.d.ts +8 -1
- package/dist/advance.d.ts.map +1 -1
- package/dist/advance.js +39 -6
- package/dist/advance.js.map +1 -1
- package/dist/apply-decide.d.ts +7 -1
- package/dist/apply-decide.d.ts.map +1 -1
- package/dist/apply-decide.js +23 -10
- package/dist/apply-decide.js.map +1 -1
- package/dist/apply-persist.d.ts +52 -8
- package/dist/apply-persist.d.ts.map +1 -1
- package/dist/apply-persist.js +78 -9
- package/dist/apply-persist.js.map +1 -1
- package/dist/do.d.ts.map +1 -1
- package/dist/do.js +71 -2
- package/dist/do.js.map +1 -1
- package/dist/frontmatter.d.ts +17 -10
- package/dist/frontmatter.d.ts.map +1 -1
- package/dist/frontmatter.js +3 -3
- package/dist/frontmatter.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/protocol/WORK-CONTRACT.md +2 -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/skills/setup/protocol/WORK-CONTRACT.md +2 -1
- package/dist/skills/triage-observations/SKILL.md +1 -1
- package/dist/triage-gate.d.ts +4 -2
- package/dist/triage-gate.d.ts.map +1 -1
- package/dist/triage-gate.js.map +1 -1
- package/dist/triage-persist.d.ts +55 -5
- package/dist/triage-persist.d.ts.map +1 -1
- package/dist/triage-persist.js +68 -7
- package/dist/triage-persist.js.map +1 -1
- package/package.json +1 -1
- package/src/advance.ts +52 -4
- package/src/apply-decide.ts +23 -10
- package/src/apply-persist.ts +86 -9
- package/src/do.ts +88 -2
- package/src/frontmatter.ts +17 -10
- package/src/pi-harness.ts +102 -2
- package/src/reap-agent-tree.ts +19 -3
- package/src/triage-gate.ts +4 -2
- 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
|
|
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)
|
|
955
|
-
//
|
|
956
|
-
//
|
|
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});
|
package/src/apply-decide.ts
CHANGED
|
@@ -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).
|
|
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
|
|
205
|
-
` task/spec/adr)
|
|
206
|
-
`
|
|
207
|
-
` answer
|
|
208
|
-
` harvested into the item body
|
|
209
|
-
`
|
|
210
|
-
`
|
|
211
|
-
`
|
|
212
|
-
`
|
|
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.`,
|
package/src/apply-persist.ts
CHANGED
|
@@ -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
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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 =
|
|
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 {
|
|
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) {
|
package/src/frontmatter.ts
CHANGED
|
@@ -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
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
|
245
|
-
* `triaged:
|
|
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
|
-
|
|
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/triage-gate.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|