Skip to content

Releases: wighawag/dorfl

dorfl@0.13.4

Choose a tag to compare

@github-actions github-actions released this 05 Sep 15:18
d8505db

Patch Changes

  • fe812b5: Clear a stranded needsAnswers gate on a shipped item that has no sidecar, and stop draining a drifted tasked spec.

    The terminal-state reconciliation added in 0.13.3 settles a bounced-then-rebuilt item's stranded question state, but it enumerates only work/questions/ and reasons outward to the item. So it can only ever see residue that still HAS a sidecar, and it explicitly declined the opposite shape on the grounds that needsAnswers: true with no sidecar is the legal pre-surface state.

    That conflated the state with its POSITION, and it left the harmful half unreachable. A bare flag is legal in a POOL or STAGING folder, where it is the surface rung's input and clearing it would disarm gated work. On tasks/done/ it cannot mean that: the task has shipped, no rung enumerates it, so surface can never run on it again and no answer can still be typed. There it is pure residue, and it is the half that actually gates anything, being the state the item would be re-opened into.

    Three ordinary routes produce it, each removing the sidecar while the flag survives, after which nothing in dorfl could reach it: the item was NEVER surfaced (the tasker sets needsAnswers: true on an uncertain task, and the flag is only a warning on the human claim path, so the task can be answered in conversation, built, and completed with the flag riding the done-move into tasks/done/); a human tidied work/questions/ by hand, the obvious manual clean-up for the paired residue, which deletes exactly the handle the sweep needs; or the drain's own defense-in-depth guard, which removes the sidecar unconditionally but skips the flag clear on a body it cannot annotate.

    The pass now runs TWO enumerations against the same base and in the same commit: the sidecar set as before, plus a git grep over the success-terminal folders for flagged bodies with no sidecar. The grep only SHORTLISTS (those folders are the repo's largest and this runs on the claim path); the decision is always the parsed frontmatter, since bodies discuss needsAnswers in prose constantly, and the value is matched loosely (optional quote, any case) because toBoolean unquotes and lower-cases, so a tighter needle would silently skip real gates. core.quotePath=false and --full-name with :(top,literal) pathspecs are pinned, or a non-ASCII path is skipped for ever and the whole half becomes a no-op when a command runs from a subdirectory. The two enumerations are kept disjoint, so the answered-sidecar carve-out cannot be bypassed by a path that clears the gate while leaving the unread answer, and a cleared flag is reported from what was actually STAGED rather than what was intended.

    It also FIXES a previously-shipped defect of the same family, found while reviewing the above: specs/tasked/ was treated as a terminal where question state is moot, so a drifted tasked spec's PENDING sidecar (an unanswered human decision) was deleted and its gate cleared. WORK-CONTRACT ("A SPEC that has drifted AFTER it was TASKED") makes needsAnswers: true on a tasked spec legal and load-bearing, and lifecycle-gather.ts enumerates tasked resting specs unconditionally, routing a bare flag to SURFACE and an answered sidecar to APPLY. Both halves are therefore live inputs to a rung that WILL run, so specs/tasked/ is now excluded from the question-residue terminal map entirely. The LOCK terminal set is unchanged: a tasked spec is still terminal for locks.

    Unchanged: the surface path and its atomicity; the cancelled/dropped asymmetry (a bare flag on a won't-proceed terminal is accurate history and is KEPT); the answered-sidecar hold; advance-classify.ts's invariant-violation kind and both reason tags; and orphan-sidecar.ts's terminal-is-not-orphan guard. status stays strictly read-only and now reports both shapes of the residue under one heading.

  • f80e1e0: Stop setFrontmatterMarker demoting a BOM-prefixed document's frontmatter into its body.

    The READER and the WRITER disagreed about whether a document has frontmatter. extractBlock (behind parseFrontmatter) strips a leading BOM before looking for the --- fence, so a BOM'd body parsed fine and reported its slug, title and spec. setFrontmatterMarker did not strip it, so the same body failed its startsWith('---\n') test, was judged FENCE-LESS, and had a SECOND fence prepended. The original frontmatter was pushed down into the body, where it is prose: every key except the one just written silently stopped existing.

    The corruption was invisible to the guards meant to catch exactly this. Both the surface path and the terminal-question drain re-parse their own output and refuse to write a body whose marker does not read back correctly, but the marker in the newly prepended fence reads back perfectly, so the write was committed with the rest of the metadata destroyed.

    A BOM is rare in a work/ tree but entirely legal, and it is what a Windows editor or an external tool produces. It is now stripped for the analysis in the same way the reader strips it, and re-prepended to the result: it is the document's encoding marker, not the tool's to drop. A BOM'd fence-less document still gets its fence prepended, after the BOM.

dorfl@0.13.3

Choose a tag to compare

@github-actions github-actions released this 05 Sep 09:30
2075f87

Patch Changes

  • c814e57: Release a propose-mode per-item lock once its item is terminal on main, so completed, merged work stops reporting as in-progress for ever.

    complete --propose deliberately keeps the lock held after opening the PR, and says so: keeping the per-item lock HELD (propose PR open; the work is not yet on main). It is released when the PR merges (reconciled against main). Holding it is right, because the done-move is on the PR branch and main still shows the body in the ready pool, so releasing there would let the next tick re-claim an item under review. The second sentence, however, never happened. Nobody runs a dorfl process at the moment a human clicks merge on GitHub, there is no merge hook and no daemon, so a release scheduled for merge-time could never fire. reconcileItemLockAgainstMain had implemented exactly the right decision since the lock cutover, but had no caller on any ordinary path: it was reachable only from the opt-in gc --ledger --reap-stale-locks sweep, which an operator driving do/complete by hand never runs. Every propose build therefore leaked its lock ref permanently. On one arbiter this reached 26 refs, every one naming a task at rest in work/tasks/done/, all listed by status under "In progress (lock held)". The --merge path never showed this because it lands on main inline and releases in-process.

    The bug has two halves and they are fixed in two different places.

    The mis-reporting half is fixed in the read commands. status and scan now classify each held lock against the arbiter's main and report one whose item is already at rest as "Completed, lock not yet released", excluded from the in-flight list, so finished work no longer reads as in-progress. This classification writes nothing: status and scan remain strictly read-only, as their own descriptions promise.

    The leaked-ref half is fixed on the claim path, which already writes to the arbiter, already fetches main, and runs on every unit of work. The stale terminal locks are released there, so the leaked set drains continuously as a side effect of ordinary use, with no human ever routed to a clean-up verb. That routing failure is the whole reason the leak grew to 26: gc --ledger had been reporting these very locks and printing the release-lock command all along.

    status --reconcile-locks and scan --reconcile-locks run the same sweep on demand, for draining an existing backlog of refs immediately. They are a convenience, not the mechanism.

    What a lock means is unchanged. The terminal test is the item's position on <arbiter>/main and nothing else: a task resting in tasks/done/ or tasks/cancelled/, a spec in specs/tasked/ or specs/dropped/. Never a branch, never a PR's existence or merge status, never the holder, never age. An item on an open PR still shows its body in the pool on main, so it keeps its lock, as does a genuinely stuck one; releasing a non-terminal lock would let two claimants build the same item, which is a much worse failure than the leak. Every uncertainty (unreadable main, an unclassifiable pre-cutover entry, any fault) resolves to keep, and --reconcile-locks authorises the sweep without widening that predicate by one inch. Only the terminal class is swept: the crash-window orphan (non-terminal but surfaced with needsAnswers:true plus a sidecar) remains the business of gc --ledger --reap-stale-locks.

    Every delete is the same --force-with-lease delete release-lock and requeue already use, so a lock a concurrent writer moved is reported rather than stolen, never force-deleted. The sweep never throws and never fails a claim, which is unrelated work: it is opportunistic hygiene, not a precondition. It is idempotent. The recovery verbs are untouched: requeue, requeue --reset, release-lock and release-lock --entry <literal> keep their exact semantics, and a pre-cutover entry with no derivable item-form is left held for the --entry escape hatch.

    Reading the arbiter's main is ref-shape dependent, and getting it wrong fails silently, so it is handled explicitly. A working clone holds the arbiter's main at refs/remotes/<arbiter>/main; a bare hub mirror (what the registry stores) has no refs/remotes/* namespace at all and holds it at refs/heads/main. Probing the wrong ref fails with invalid object name, which is indistinguishable from "not terminal", so every lock would classify as in-flight and the whole sweep would become a permanent no-op on the registry surface. The refresh therefore uses an explicit refspec that writes exactly the ref the probe reads, and the classifier verifies that ref resolves before probing, reporting an error rather than quietly answering "nothing is terminal". Regression tests cover both repo shapes.

    complete --propose's own message is corrected too. It used to promise "It is released when the PR merges (reconciled against main)", the sentence that was never true; it now says the item comes to rest on main when the PR merges and the lock is released by the next claim, naming a trigger that actually fires.

    The design decision, including why the automatic release belongs on a write path rather than on status, is recorded in docs/adr/terminal-state-reconciled-by-claim-not-by-read-commands.md.

    Verified against the live 26-ref corpus, cloned into a throwaway sandbox so the real arbiter was never written to. In both repo shapes, a working clone and a bare mirror, the read-only classifier identifies all 26 as terminal, zero in flight, zero errors, and leaves all 26 refs in place; the sweep then releases all 26 with zero kept and zero errors, and a second pass is a clean no-op.


    The same change also clears the STRANDED QUESTION STATE a bounced-then-rebuilt item leaves behind, because it is the same defect wearing a second hat and is settled by the same pass at the same moment.

    When a build bounces, the surface path atomically writes both halves of the item's question state in one commit: the sidecar work/questions/<type>-<slug>.md, and needsAnswers: true on the item body. That is correct, and its atomicity is what makes reconciliation decidable at all. But if the human disagrees with the agent, re-dispatches, and the rebuild SUCCEEDS (PR opened, gate green, merged, body done-moved), neither half is ever cleared. Items come to rest in tasks/done/ still carrying a question asking whether to CANCEL them, with a destructive default. The flag is the worse half: it is a gate left armed over shipped work, and it makes status report finished (sometimes released) work under "open questions block autonomous work".

    Dorfl already knew this state was illegal. advance-classify.ts refuses it as invariant-violation with the tag sidecar-without-needsAnswers. The detector simply lived in the advance tick's classifier, and a human driving do and merging a PR never enters that loop. Both defects share a cause (cleared by a step that only runs on a path the item did not take), a moment (the done-move landing on main), and a blind spot (detectable only from a loop the manual path never enters), so there is ONE reconciliation, reconcileTerminalState, not two mechanisms. The classifier keeps its invariant-violation kind and both reason tags: reconciliation now prevents the state arising, and the classifier remains the backstop.

    The drain publishes ONE tree-less commit to the arbiter's main through the same contention-retry and CAS-publish core the surface path uses, so there is no second write mechanism, and the batch lands or does not land atomically.

    The trap this had to avoid is that the MIRROR state is legal and common: needsAnswers: true with no sidecar is exactly what an item authored with open questions looks like before surface runs, and that flagged-but-unsurfaced item is the surface rung's own input. Reconciling "flag without sidecar" would silently disarm every un-surfaced item in the repo and hand gated work to agents. So the terminal POSITION is the discriminator, never the flag/sidecar disagreement on its own; the enumeration is anchored on the sidecar set rather than on flags; and an item resting in a pool or staging folder keeps whatever state it has. Two negative regression tests pin this, and a simulated careless fix (treating a pool item as terminal) makes one of them fail.

    Two asymmetries are deliberate. A cancelled/dropped item has its stale sidecar removed but KEEPS needsAnswers: true, because an item can be cancelled precisely because its questions were never answered, so there the flag is accurate history rather than residue; it gates nothing, since a terminal item is in no pool. And a sidecar carrying any ANSWERED entry is never auto-drained: in the field one had been answered in writing, ending "Close this sidecar", and was still sitting there, which is evidence the drain does not run on the human-answer path either. That is a separate defect, and this pass refuses to paper over it by destroying the evidence, so such an item is left untouched and reported instead.

    status reports both halves of the residue read-only, under "Completed, question state not yet cleared" and "Answered but never applied".

    The question drain is strictly opportunistic on the claim path. It is guarded so that no fault in it can fail the operator's actual work: a review found that a pre-existing loose ref at refs/dorfl/question-drain makes the batch scratch ref un-creatable, and the git plumbing threw out of the claim path, surfacing as exit 1 with no lock taken. That is now caught at two levels and reported, and a regression test pins the claim still succeeding. A protected main degrades the same way: the drain cannot land, so it reports and leaves the residue exactly as it was.

    The residue is RE-DERIVED against the base each contention attempt commits on, rather than being carried over from t...

Read more

dorfl@0.13.2

Choose a tag to compare

@github-actions github-actions released this 04 Sep 06:52
9362375

Patch Changes

  • 81528cb: Release the tasking lock when the tasking agent crashes, so a failed do spec:<slug> no longer strands a lock that every retry then loses the CAS to.

    Observed in the field on one spec across three consecutive runs, each killed by a model-API fault (Connection error., overloaded_error, api_error). do spec:<slug> takes refs/dorfl/lock/spec-<slug> (action: task, state: active) and then invokes the tasking agent; when the agent died, performTask returned agent-failed while leaving the lock held, on the stated grounds that "surfacing it is the review/edit loop's job". That loop lives at step 3.5, strictly AFTER the agent-failure return, so it never ran. The lock outlived every process that knew about it and each retry refused with 'spec-<slug>' is already locked (held by another). Back off.

    The lock is now released on that path. A crashed tasking run publishes NOTHING to the arbiter (the work branch is created with a local git switch -C and is not pushed before the integrate band, and the durable specs/ready → specs/tasked move also happens only at integrate), so releasing discards nothing and returns the spec to the taskable pool: the recovery is simply to re-run the command. The release is deliberately a PLAIN release, not the needsAnswers:true + question-sidecar surface the sibling ReviewParseError path uses. That surface is right for a review VERDICT (a judgement a human must resolve) and wrong for a transport crash, which carries no judgement and would otherwise be turned into mandatory human paperwork behind a contentless question. A release fault is reported but never masks the agent failure, and the crash is still reported faithfully rather than laundered into a success.

    The capability to clear such a lock by hand already existed and was simply undiscoverable: dorfl release-lock spec:<slug> releases a spec- entry today, and gc --ledger reports every held lock and prints that exact command. The operator hit three dead ends instead. Those messages are fixed rather than duplicated into a second release mechanism:

    • The contention refusal now names the recovery. Back off. remains the default, because contention between two live runners is the common case and a runner must never be nudged into stealing a healthy peer's lock; the escape is offered strictly under the operator-asserted condition "if the holder is DEAD", pointing at gc --ledger to inspect and release-lock to clear. The suggestion is derived purely (no extra round-trip on a hot path) and falls back to the --entry escape hatch for a pre-cutover entry.
    • requeue <slug> no longer misdirects. It is a task-only verb, so a bare slug resolves to task-<slug> and found nothing while refs/dorfl/lock/spec-<slug> sat on the arbiter; the blunt "wrong slug, or already at rest?" sent the operator hunting for a typo. It now probes the spec namespace and, on a hit, names the held lock, its action/state/holder/since, and release-lock spec:<slug>. It still refuses to act: requeue's contract is keep/continue/rebase/reset/reconcile of a WORK BRANCH, and a tasking run has no work branch, so it emits a pointer rather than forking a second way to delete the ref release-lock already owns.

    Note for anyone reading the diff: an existing unit test asserted released === false on this path, pinning the defect. Its justification ("recoverable/re-run") was exactly what the stranded lock made impossible, so the assertion is inverted deliberately, with the reasoning recorded at the test.

dorfl@0.13.1

Choose a tag to compare

@github-actions github-actions released this 24 Aug 06:26
a7141b3

Patch Changes

  • d683dc8: A do (including --isolated / --remote) job's record now says what actually ran and how the run ended, instead of createJob's placeholder forever.

    Until now the whole do path never called updateJobRecord, so every job record kept its initial values — harness: {adapter: "null"} and state: "running" — for its entire life, healthy run or not. Only run finalised the record (run.ts writes the launch's real harness record and maps the terminal outcome onto state). In the field this sent an investigation down the wrong path twice: a healthy pi run's record read as "the null harness was launched with an empty agentCmd", and a run that had reached a terminal decision read as "still in flight" because nothing ever moved it off running. Both were the placeholder, not facts.

    Two changes, mirroring run's discipline exactly:

    1. runDoAgent/launchAgentUnderWriterLock now thread the launch's REAL HarnessRecord (adapter + pid/session liveness anchor) out, and the no-checkout pipeline writes it with updateJobRecord(cwd, {harness: ...}) the moment the launch settles. The pid/session anchor is also what status/gc need to answer liveness for a do-path job at all.
    2. performDoRemote finalises the record to match the terminal outcome before teardown: completeddone; the needs-attention family, the failure-cause axis, agent-stopped, deadline-surfaced, and refusals → needs-attention with the pipeline's own message as the recorded reason (so status surfaces WHY without re-deriving it). deadline-auto-continued and lost/contended claims are deliberately untouched — nothing needs attention, and a retained checkpoint job genuinely is not over, the next claim continues it. A pipeline that THREW skips the write: that failure is already loud, and the retained record now carries the real harness anchor so status reads it as crashed-running-but-dead, which is honest.

    The in-place do has no job record (there is no job worktree), so nothing changes there; updateJobRecord is a no-op without an existing record either way.

    Adds end-to-end tests driving the real performDoRemote with a stub harness and the arbiter taken offline mid-run (so the worktree + record are retained and readable): the record carries the stub's adapter/pid/session rather than the placeholder, a failed agent lands needs-attention with the failure detail as the reason, and a deadline route records itself instead of a bare running. All three fail against the pre-fix code.

  • 4efa3f8: Stop the runner exiting 0 in the middle of its own deadline checkpoint, leaving the item locked, the work uncommitted, and the run reported as a success.

    Observed in the field twice on one task (dorfl 0.13.0, harness pi, do task:<slug> --isolated --allow-backlog --propose --no-review, agentDeadlineMinutes: 90): both runs printed only the claim/onboard preamble, then went silent and exited 0 after exactly 90 minutes. No acceptance gate, no commit, no pushed branch, no PR. The item lock was left implement/active, the job record left "state": "running", and roughly 35 files of real agent work were left unsaved in the job worktree. Ten shorter tasks in the same session with the same config were fine, because only a run that actually reaches the deadline takes this path.

    An await is not a handle. Node keeps a process alive for referenced HANDLES (timers, sockets, child processes); a suspended promise is none of those. On the deadline path PiHarness.launchAsync deliberately drops every handle it owns the moment pi exits (it destroys the stdio pipes and unrefs the child so a leaked grandchild's inherited FDs cannot pin the loop) and then keeps the launch promise PENDING across reapProcessGroup, whose poll timer was itself unref'd. So whenever any group member was still alive at the reap's first probe (a tool subshell, a test runner, an MCP server, or in the observed run the sleep the agent was parked in), the event loop had nothing referenced left, node did the correct thing with an empty loop and exited normally with code 0, and the whole suspended pipeline never ran: no WIP commit, no branch push, no lock release or needs-attention surface, no worktree-writer-sentinel release, no job-record update. The exit status was a genuine 0, so nothing upstream could tell it from a completed build.

    The reap being the sole remaining handle-holder is not an accident of that one timer, so the fix has two independent layers:

    1. reap-agent-tree.ts's sleep timer is REFERENCED. The reap is bounded by construction (sigtermGrace + sigkillTimeout), which is what made the unref look free; it was not, because by then it is the only thing standing between the runner and an empty loop.
    2. PiHarness.launchAsync holds an explicit referenced keep-alive for exactly as long as the launch is unsettled, and arms a process exit guard that turns any OTHER way of exiting mid-launch into a LOUD, non-zero failure naming the recovery (requeue) instead of a silent success. The keep-alive prevents the known mechanism; the guard refuses to let a future variant of it be mistaken for a clean run. It is released on both settle paths, so a failed spawn cannot pin the loop.

    Two field beliefs this corrects, both wrong and both costly at the time: the run's harness: {"adapter": "null"} in ~/.dorfl/work/<work-id>.json is NOT evidence that a null harness ran (createJob writes {adapter: 'null'} as the initial value, jobWorktreeStrategy passes no harness, and the whole do path never calls updateJobRecord at all, so every do job reads that way whether healthy or not; only run finalises the record), and the options.harness ?? new NullHarness() fallback cannot fire from any CLI path, since createHarness always constructs and threads an instance. Neither was the cause.

    Adds regression tests that run a BARE runner process, because the defect is invisible in-process: vitest's own handles keep the loop alive, which is exactly why the existing in-process deadline/reap suite passed throughout. Without the fix the new test reproduces the field symptom byte for byte (preamble printed, nothing after the await, no finally, exit 0).

dorfl@0.13.0

Choose a tag to compare

@github-actions github-actions released this 11 Aug 18:38
62d2322

Minor Changes

  • 6d0fbc4: Stop the advance engine re-asking a question the human already answered: a resolved note now rests triaged:, and resolve means the QUESTION-LOOP is settled, not that the note is spent.

    An observation that a human triaged and the apply rung resolved with the resolve (resolve-no-mint) verdict was re-triaged and re-asked the IDENTICAL question on every later cycle, forever. Observed live in a consumer repo: for observation:any-casts-in-deploy-proxy-diamond, one commit surfaced a question, the human answered it, the next resolved it, and the next surfaced the same question again. Diffing the sidecar the resolve deleted against the one the re-surface created showed the only difference was the human's answer prose being absent; the question text was byte-identical. A single cycle hit four notes, with eleven more resting in the same shape behind them.

    The cause is that a resolved-and-kept note is INDISTINGUISHABLE from a never-triaged one. classifyTick reads exactly two signals, needsAnswers and the active sidecar, and its first branch is: needsAnswers not true, no sidecar, therefore ANALYSE, therefore triage-observation. The resolve verdict produces precisely that state (it clears needsAnswers, harvests the answers into ## Applied answers, and deletes the sidecar) while KEEPING the note in the inbox the triage rung re-scans. The re-asked question is not an agent flake: it is the DETERMINISTIC, engine-built triage question, which is why the sidecars diff byte-identical.

    The read side of the fix already existed end to end and only the writer was missing. frontmatter.ts parses the triaged: settled marker, ledger-read.ts carries it, lifecycle-pools.ts has an explicit branch dropping a marked observation out of the create-side triage pool, and the triage rung already no-ops on it for an explicit obs:<slug>, with a test already pinning that end state. The triaged: WRITER was removed when the human-stamped disposition=/promote-* TOKEN vocabulary was retired, and resolve was added afterwards without noticing it had re-created the one case that needs it. That earlier change fixed an ask loop (the decider had no valid verdict for "answered, mint nothing, keep it" and looped on ask) and traded it for this triage loop, because it treated resolve as purely a routing question, which existing persist path to call, when it is also a lifecycle question, what the note looks like at rest.

    Four changes:

    1. The resolve-fully path stamps triaged: resolve on an OBSERVATION, in the SAME atomic commit that clears needsAnswers and deletes the sidecar, so no tick can observe a torn "kept but still untriaged" state. The stamp is structural in apply-persist.ts (derived from the item identity, never passed by the caller), so no route into that path can forget it. A TASK or SPEC is deliberately NOT stamped: its status is its folder, it is not enumerated from the observation inbox, and there is no triage rung to re-ask it.
    2. resolve now MEANS "the question-loop is settled AND this note is still a live signal". The decision prompt picks between resolve and dispose on LIVENESS, not politeness: if the answer means the signal is finished (the thing was fixed, it was already covered elsewhere, it is obsolete, or it is now wholly carried by a task/spec/ADR/commit), the verdict is dispose and the note leaves the inbox by deletion. Keeping a note merely to show it was handled is the backward-artifact-in-a-forward-bucket the contract forbids.
    3. WORK-CONTRACT.md gains the matching cell. It said there is "no triaged: / needsAnswers:false resting state", but said it of a DISCHARGED note; it had no cell for a note an answer deliberately KEEPS, which is why the engine had nowhere honest to record one. A kept, triaged note now rests as triaged: <disposition> + needsAnswers: false + no sidecar + the harvested answers in its body, and the marker explicitly means the question-loop is settled, NEVER that the signal is finished. The absolute claim that "a note annotated resolved and kept is a contradiction" is narrowed to a note kept only to narrate that it was dealt with.
    4. Notes resolved-and-kept BEFORE the stamp existed are back-filled by the triage rung, once each. A forward-only fix would leave them re-asked forever and they cannot fix themselves, since answering again just re-resolves into the same shape. The trigger is proof rather than a heuristic: only the apply persist writes the ## Applied answers heading, and only after a human answered every open question, so its presence on an unmarked note means the engine already resolved it. The rung stamps what the apply rung would have written and no-ops; the stamped note then drops out of the pool, so the arm cannot fire twice for one note.

    Deleting the note on resolve instead was considered and rejected on the evidence. The four real answers in the consumer repo explicitly instruct retention ("Verified still live and still accurate, so it remains a useful standing map"; and, three times, "Keep the note until the residue above is either acted on or judged not worth acting on; it is the only record of these choices outside the code"). Those notes have not stopped being live signals; their QUESTION has been answered. Deleting them would destroy the only record of accepted, user-visible residue against an explicit instruction to keep it, and the deletion would be the agent's judgement rather than the human's answer, which is the one thing the capture-bucket rule bars. The honest half of that argument is kept where it belongs, in the verdict's guidance, so resolve cannot become a soft dispose.

    The needsAnswers:false implies no-active-sidecar invariant is untouched: the resolve path still clears the flag and deletes the sidecar in one commit, and the marker is an independent third frontmatter axis the classifier never reads, so classifyTick keeps its two-signal contract. Settling is not silencing either: an ANSWERED sidecar still dominates the marker, so a genuinely new question about a settled note still routes to apply and is still acted on, and re-resolving simply re-stamps. To put a note back into ordinary triage, delete its triaged: line.

    work/protocol/ is re-synced from the skills/setup/protocol/ source of truth. A consumer repo converges by running dorfl sync (or re-running setup) for the docs, plus upgrading the dorfl package for the stamp and the back-fill; already-stranded notes then self-heal at one no-op advance leg each.

    Adds regression tests: two full advance cycles over one resolved note, where the second is a no-op rather than a re-surface (with a surface gate that throws, so merely reaching the question agent fails the test); a genuinely new answered question on an already-resolved note still routing to apply and minting; the persist stamping an observation (including a fence-less one) but not a task, and not on a re-pause; and the legacy back-fill stamping once, committing, and staying idempotent.

dorfl@0.12.0

Choose a tag to compare

@github-actions github-actions released this 11 Aug 13:48
5e859fb

Minor Changes

  • f9ab9a4: Give a build agent's rationale ONE reachable home: the runner now transcribes the agent's ## Decisions block into the done record.

    Three separate symptoms in a consumer repo turned out to be one defect. Seven notes named decisions-<task-slug>.md had been invented in work/notes/observations/ (a bucket documented as "spotted, unverified, append-only") to hold judgement calls awaiting ratification; the identical review finding "no Decisions block in the commit body" had been raised on seven separate tasks; and a load-bearing measurement sat in docs/spikes/<slug>/ while work/notes/findings/ stood empty.

    The root cause was structural, not agent error. The contract offered a builder three homes for an in-scope decision, and TWO of them were unreachable BY CONSTRUCTION: the done record (the builder must not edit the task body, and the ready → done move is the runner's) and the PR body (runner-authored). Only "a JSDoc at the choice site" and "an observation note" were reachable, so that is where rationale went. An acceptance criterion phrased "rationale recorded in the done record" was therefore unsatisfiable as written, which is why reviewers could re-raise the same finding indefinitely without any agent being at fault. The parser for the intended mechanism already existed (extractDecisionsBlock in agent-stop.ts, fully unit-tested) and was called from nowhere: the channel was specified and never wired.

    Four changes:

    1. The runner transcribes the block. performIntegration now appends the agent's ## Decisions block verbatim to work/tasks/done/<slug>.md, AFTER the git mv (so it is written at the done path) and BEFORE git add -A (so it rides the ONE atomic completion commit, never a second one). It is idempotent (a record already carrying a ## Decisions section is left untouched, so a requeue continue or a re-run over an already-done-moved branch cannot double-append), skipped for a tasking transition (a spec has no done record), and best-effort (a missing/unreadable record is a silent no-op: a rationale note must never fail an otherwise-green completion). The builder still does no git and still never edits the task body; it emits the block on the one surface it owns, its final report.
    2. The contract states the rule in one place. WORK-CONTRACT.md gains "Where a BUILDER's RATIONALE lives": the ## Decisions block is the single sanctioned channel, an ADR in docs/adr/ is the escalation for decisions meeting the ADR gate, and a JSDoc at the choice site is a welcome code-adjacent copy but never a substitute. It also states explicitly that there is NO pending-ratification note kind and that a decisions-<slug>.md note must not be opened: a decision you made and verified is neither spotted nor unverified, and such a note is a BACKWARD artifact parked in a LIVE, FORWARD bucket, so it can never be discharged and accumulates forever.
    3. Taskers stop minting the unsatisfiable criterion. TASKING-PROTOCOL.md forbids an acceptance criterion that asks the builder to write the done record, the commit message, or the PR body, and directs taskers to name an artifact the builder CAN create (an ADR) when a decision must be durable in its own right. CLAIM-PROTOCOL.md and task-template.md are updated to match, so the in-band prompt names the block as the channel. The reviewer prompt now reviews the DECISION rather than the filing, and is told not to raise findings about where rationale was written, since those locations are the runner's to author.
    4. findings/ accepts a measurement whose provenance is a committed script. What makes a finding correctable is that its source: is nameable and re-runnable, not that it is an external URL. The evidence (probe script, raw output) stays at docs/spikes/<slug>/ (the stable, non-flowing home of conflict-safety rule 8) while the knowledge lives in work/notes/findings/<slug>.md with a source: naming the script, its commit, what it ran against and when. When a measurement is LOAD-BEARING, meaning some capability is withheld or enabled because of it, the finding is now REQUIRED: a spike folder alone leaves the reason undiscoverable, so the next agent re-litigates the measurement or silently reverses the behaviour it justifies.

    Because prompt.ts extracts the work-agent wrapper directly from CLAIM-PROTOCOL.md, the doc edit IS the runtime prompt, so there is no second copy to drift. work/protocol/ is re-synced from the skills/setup/protocol/ source of truth and VERSION is bumped to 2026-08-11. A consumer repo converges by running dorfl sync (or re-running setup) for the docs, plus upgrading the dorfl package for the transcription behaviour; until the package is upgraded a builder's block still reaches the reviewer through the PR body, just not the done record.

    Adds a regression suite driving the shared integration core: a block is appended verbatim to the done record in the completion commit (without dragging in surrounding report prose, and without adding a second commit), a report with no block leaves the record untouched, an absent report is a no-op, and a record already carrying a ## Decisions section is not double-appended.

dorfl@0.11.3

Choose a tag to compare

@github-actions github-actions released this 10 Aug 21:59
12feb45

Patch Changes

  • 72963e1: Fix dorfl gc --ledger reporting locks that do not exist on the arbiter.

    gc --ledger reported "STALE / crash-window orphan" locks and printed a dorfl release-lock <item> for each, which then failed with "(stale info)" / "already absent on origin" — naming locks a human should delete that did not actually exist. Verified: git ls-remote origin 'refs/dorfl/lock/*' returned nothing while the hub mirror held 8 refs/dorfl/lock/* refs, three of them for locks released on origin earlier in the same session.

    The root cause was structural. The lock readers (listItemLocks, listItemLockEntries, readItemLock, fetchHeldEntry, the acquire/release/reconcile paths) did git fetch <arbiter> +refs/dorfl/lock/*:refs/dorfl/lock/* — force-update with NO --prune — then read the LOCAL refs/dorfl/lock/* via for-each-ref / git show <ref>. A lock released on the arbiter (its ref deleted there) survived locally indefinitely, so the readers reported locks the arbiter no longer held. The same accumulation also affected the SELECTION path (heldTaskSlugsStrict/heldSpecSlugsStrict via listItemLocks): a released task's stale local ref kept it subtracted from the eligible pool, so a released task could not be re-tasked. Separately, ensureMirror synced only refs/heads/* (and main), never refs/dorfl/lock/*, so the mirror's lock namespace was never pruned and released locks accumulated there too.

    Two fixes:

    1. Prune refs/dorfl/lock/* when syncing the mirror. ensureMirror now best-effort --prune fetches the per-item lock namespace from the arbiter, so released locks cannot accumulate on the mirror.
    2. The ledger reads the arbiter directly. listItemLockEntries (the gc --ledger report reader) now takes the authoritative lock list from git ls-remote <arbiter> refs/dorfl/lock/* — ONLY the refs that actually exist on the arbiter right now — so the report can never name a lock that does not exist. The content is materialized by a --prune fetch (which also keeps the local namespace clean). listItemLocks, readItemLock, fetchHeldEntry, and the acquire/release/reconcile fetches all add --prune, so a released lock's stale local ref is pruned and for-each-ref/git show read the arbiter's actual state. listItemLocks keeps materializing the refs locally (not ls-remote-only) because migrateStuckLocks reads a lock's body via git show <ref>:lock.md after it.

    A report whose entire purpose is to name locks a human should delete can no longer name locks that do not exist. Adds regression tests: a mirror holding a lock ref the arbiter does not is pruned on the next ensureMirror sync; and listItemLocks/listItemLockEntries/reportItemLocks/readItemLock do not report a stale local lock ref the arbiter no longer holds (while a genuinely held lock is still reported).

  • b2576c0: Fix the tasker-review leg crashing dorfl do spec:<slug> on any large spec.

    Running dorfl do spec:<slug> --isolated --propose on a substantial spec printed review agent produced no parseable {verdict, findings} result and died — no task branch, no PR, nothing emitted — leaving the tasking lock held (cleared only by hand via dorfl release-lock) and writing no work/questions/spec-<slug>.md sidecar. It was deterministic, not a flaky model: the tasker itself produced a good decomposition, but the review leg failed and the whole run was discarded with it.

    The root cause was structural and upstream of the parser. verdictContractPrompt asked the review agent to emit the FULL replacement body of every edited task file inline as edits: [{path, content: "<full replacement>"}] in the SAME single JSON object as the verdict — an unbounded payload sharing one capped model response with the verdict. The richer the spec, the more edit body bytes, the more certain the response cap-truncated mid-object before the verdict closed. extractJsonObjectSpan then returned undefined, parseReviewVerdict threw, and the throw propagated out of performTask (there was no try/catch around the review loop). The parser was correct to refuse to invent a verdict and is left unchanged.

    Four fixes, in priority order:

    1. The unbounded full-file edits payload is moved off the capped response. The review agent now WRITES each edited task body to a scratch file under work/tasks/.review-edits/ and references it by src in the verdict JSON (a new optional TaskEdit.src channel; inline content is kept as the legacy small-edit form). The verdict JSON now carries only paths, so its size is bounded by the NUMBER of edits, not the total body size — a large decomposition can no longer cap-truncate the verdict. The runner reads the scratch body, applies it through the SAME scope fence (unchanged), and reaps the scratch so the integrate's git add -A never sweeps it. Because the agent writes to scratch (not the target), the tasker's pristine candidate tasks stay untouched until the verdict parses.
    2. Cap-truncation is now detected and NAMED at the harness seam. LaunchResult carries a new outputCapped signal (the observed usage.output token count), populated by the pi adapter from the session log's last assistant turn's stop_reason (null/None/max_tokens) + usage.output. When a parse fails AND that signal is present, the gate throws the new ReviewOutputCappedError ("review agent output hit the model output cap (16384 tokens) and was truncated before emitting its verdict") instead of the generic parse error — so an operator no longer mis-reads it as a flake and retries blindly, burning a second full tasking run. A verdict that DID close on a capped turn is still honored. When the adapter cannot see the signal (the null/shell adapter), the parse still fails as a generic ReviewParseError — still needs-attention, never a silent approve.
    3. The tasker's work is no longer discarded on this path. performTask catches the review failure and persistTaskingCandidates commits the candidate tasks to the work branch (work/spec-<slug>) with a marker commit and pushes best-effort, so a retry or a human can recover them.
    4. The lock is released and the documented bounce completed. The catch routes through the existing surfaceTaskingBlock (release the tasking lock + write the work/questions/spec-<slug>.md sidecar), identical to the decomposition-unclear path — the lock is released and the sidecar written, honouring the WORK-CONTRACT end-of-leg release on success OR bounce.

    ReviewOutputCappedError subclasses ReviewParseError so every existing catch (ReviewParseError) site routes it to needs-attention uniformly — a parse failure is never turned into a silent approve, and the narrow control-character repair pass in parseReviewVerdict is left exactly as narrow as it was. Adds regression tests driving the tasker-review gate with a response truncated at the output cap (asserting the named failure + lock release + candidate-task persistence) and a non-review throw is re-thrown.

dorfl@0.11.2

Choose a tag to compare

@github-actions github-actions released this 01 Aug 16:45
0cdc9bd

Patch Changes

  • 4e890e6: Fix the deadline checkpoint leaving two agents alive in the same worktree.

    The harness sent SIGTERM to a single pid and resolved the launch on that process's own exit, which the runner treats as "the agent is done" — so it saved the WIP, released the item lock, and let the next tick dispatch a continuation agent into the same worktree. But an agent is a process TREE (subagents, MCP servers, model proxies, tool subshells) whose members never receive the parent's signal and, once the parent exits, are re-parented to init and can no longer even be found by walking ppid. In the field a predecessor kept writing for roughly four minutes into its successor's run, and its final write landed on a file the successor had already read as clean in its opening git status. Nothing was lost only because the two happened not to touch the same file.

    A deadline stop now spawns the agent as a process-group leader, signals the whole group, and polls until the group is verifiably gone (escalating to SIGKILL after a grace, bounded by construction, with a loud failure naming what must not happen next if it will not die). The launch does not resolve until then, so the runner is never told the agent is done while it is still writing. The checkpoint refuses to release the item lock or dispatch a successor without that proof: it still saves and pushes the WIP, and surfaces a deadline checkpoint (agent NOT verifiably stopped) question instead, so only the hand-off is withheld.

    A normal (non-deadline) exit is unchanged: nothing was signalled, so nothing is reaped — in particular a process an agent deliberately left running behind a successful run is not killed. Because spawning detached would otherwise take the agent out of the runner's foreground process group and widen the documented "aborting do does not kill the spawned agent tree" gap, the launch forwards the parent's own SIGINT/SIGTERM to the group while the child is live.

    Adds a per-worktree writer sentinel as an independent backstop, since the item lock guards the ITEM and nothing guarded the WORKING TREE. It refuses to onboard a second agent into a tree that already has a live holder, keyed on the tree rather than the item. It lives in the worktree's private git directory, so it can never appear in git status and needs no new exclusion in the empty-diff backstop or gc's cleanliness predicate, and a dead holder's sentinel is treated as stale so a killed runner cannot poison a worktree.

  • 3782a87: Fix the post-write state check reporting dorfl's own successful write as absent.

    Two call sites in the checkpoint / surface path answered a state question by reading a remote-tracking ref (refs/remotes/<arbiter>/…) after a plain git fetch <arbiter>. That is unsound in the configuration --isolated itself creates: a job worktree is cut from the bare hub mirror, whose origin carries the mirror refspec +refs/heads/*:refs/heads/*, so a plain fetch never populates that namespace — and, when a work/<slug> branch is checked out, it fails outright (refusing to fetch into branch …) and refreshes nothing.

    Two consequences in the field. A surface commit that LANDED was reported push reported up-to-date / no change of our making — treating as rejected, retried to the retry cap (landing one identical commit per attempt), then declared did not land on origin/main with a recommendation to run an unnecessary recovery command. And a requeue announced '<slug>' has no work branch on origin — nothing to continue from over a branch holding an hour of agent work, one line before correctly announcing that the next tick would continue from that same branch; acting on the first message re-drives the task from scratch and discards the work.

    Post-write verification is now arbiter-authoritative through one shared seam: prune-fetch with an explicit per-branch refspec into the namespace readers actually read, then ask the arbiter itself via git ls-remote. A push that succeeded can never be reported as not landed, and an unreachable arbiter after a green push reports published rather than inventing a rejection. Tree-less transitions are idempotent at the commit level — an empty diff lands nothing, and re-surfacing a bounce whose questions are already present and unanswered is a no-op — so the commit count no longer scales with the retry budget. The requeue resolves the continue-branch exactly once and publishes that single resolved state, which callers derive their messages from, so mutually contradictory lines are structurally impossible; an unreadable arbiter now says so instead of asserting there is nothing to continue from.

    This also fixes a third consequence that had gone unnoticed: because the surface is surface-first / release-second, a surface mis-reported as failed meant the lock release never ran, leaving a bounced item both surfaced for a human and still holding a live active claim lock, which blocks re-claiming.

dorfl@0.11.1

Choose a tag to compare

@github-actions github-actions released this 29 Jul 11:20

Patch Changes

  • 2c557ab: Add an end-of-drive triage pass to the drive-tasks skill.

    Per-task Gate-3 (step 4b) already triages each task's review-nits-<slug>-*.md in isolation at the moment its PR merges, but nothing looked at the drive's nits and off-path observations as a whole. drive-tasks now runs one cross-drive triage pass (step 5.5) after the loop is exhausted and before the report: it gathers every built task's nit set plus every observation filed during the drive, reads them together (grouped by theme, so a nit recurring across PRs is the signal), and routes each item to exactly one destination (already-handled / benign-noise / kept as its own committed observation / task-worthy for to-task / a stuck-set question folded into the same batched-questions surface). It preserves golden rule 3 (no fix-in-place), runs even on a clean drive, and its disposition is a named section of the conductor's report.

dorfl@0.11.0

Choose a tag to compare

@github-actions github-actions released this 25 Jul 07:45
6fc4992

Minor Changes

  • 181182f: Scope gc to a single arbiter by default instead of reaping worktrees across every arbiter globally.

    gc used to default to a GLOBAL sweep of every job worktree under the work dir, across every repo/arbiter. But it is almost always run from inside one project, where the operator's intent is "clean up THIS repo", so a routine gc --force --yes run from repo A could silently and irreversibly discard un-pushed work belonging to an unrelated repo B (this actually happened).

    gc is now arbiter-scoped by default: it acts only on the worktrees of the arbiter resolved from the cwd (the same arbiter-resolution do --isolated/the mirror uses), so a gc in repo A can never reap repo B's worktrees. New --all-arbiters flag restores the old global cross-arbiter sweep behind a LOUD banner that names every arbiter it will touch before doing anything (and combining it with --force still requires --yes, so the destructive cross-repo path must be opted into explicitly). --arbiter <remote-or-url> targets a specific arbiter that is not the cwd's. If no cwd arbiter is resolvable and neither flag is given, gc now REFUSES with an actionable error rather than silently falling back to global.

    Behavior change: a bare gc (including in existing scripts) now scopes to the cwd's arbiter. Because this only ever makes gc LESS destructive (it can no longer reach another repo's worktrees by default), it is safe, but scripts that relied on a bare gc sweeping every arbiter must now pass --all-arbiters.