feat(tracker): a dispatch files its GitHub task ticket and a cleanup closes it - #72
Merged
Merged
Conversation
…closes it GitHub Issues held this project's horizon and none of its work. Measured 2026-08-26 on video_editing_pilot: one destination and five unknowns on the board, against two live workers, six queued items and five PRs merged in one evening. `fm:task` had existed in bin/fm-tracker.sh the whole time; filing one was simply something firstmate had to remember, and a half-adopted convention produces confident wrong answers rather than obvious failures. A frontier that answers "what is the fleet doing" with silence is worse than no frontier, because the silence reads as an answer. So the ticket is now filed by the paths that cannot be routed around. fm-spawn calls `fm-tracker.sh sync` on every crewmate and scout launch; fm-teardown calls `fm-tracker.sh complete` on every cleanup, after every refusal has passed and before the task's records are removed. A secondmate files nothing: it is a persistent direct report, not a work item. config/tracker-repos maps a project to the repository its tickets live in, matched through the project's whole alias set because data/projects.md already registers one project under more than one spelling. sync reconciles the whole open queue rather than only the dispatched task, so the frontier shows what is queued and blocked as well as what is claimed, and it reads data/backlog.md directly so either backlog backend works and a broken tasks-axi cannot stop a dispatch. Captain-held, parked and external rows are deliberately not mirrored. A dependency stated as "blocked-by: <task-id>" becomes the task-list reference GitHub turns into a real trackedIssues edge, with rows ordered so a blocker's ticket exists before the edge naming it - a reference to a number that does not exist yet is the same invisible-blocker defect arriving through ordering rather than wording. Nothing here can stop work. An unconfigured project, an absent gh, an unreachable GitHub and a hung connection all report the ticket they could not file and exit 0, and both calls are time-bounded, because a non-zero exit does not cover a connection that hangs instead of failing. The writer owns only the marker line and the blocked-by section of a body and preserves the rest verbatim. That was a correction: owning the whole body is simpler, and its first live run filed duplicates of two tickets written by hand whose bodies carried the measurements, the captain's own words and the definition of done. A mirror that destroys what someone wrote into it is not a mirror. task-open --adopt binds an existing ticket by number, never by title. Also fixes frontier's CLAIMED section, which ran every entry onto the end of the previous one. It had never rendered more than one row, because until now nothing was ever claimed.
…down now needs The fixture symlinks exactly the libraries teardown sources, so adding the bounded ticket-close made teardown fail to source and exit non-zero there. fm-tracker.sh is symlinked alongside its library rather than stubbed, so the fixture exercises the real unconfigured-project path - which is the behavior teardown depends on when a project has no tracker.
…lives elsewhere tests/fm-gotmp.test.sh drives the real teardown against a fixture that symlinks exactly the libraries teardown sources, so a change to what teardown sources breaks it - and it is classified session-bootstrap, which the pr-forge mapping for bin/fm-teardown.sh does not reach. The changed-file selection therefore ran 78 scripts green while CI failed on the one it had not selected. Named as a script, the way the tracker mapping already handles the same problem.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
GitHub Issues held this project's horizon and none of its work. Measured 2026-08-26 on
prajwal-395/video_editing_pilot: one destination and five unknowns on the board, against twolive workers, six queued items and five PRs merged in one evening.
fm:taskhad existed inbin/fm-tracker.shthe whole time. Nothing was wrong with the tool - filing a ticket wassimply something firstmate had to remember, and a half-adopted convention produces confident
wrong answers rather than obvious failures. A frontier that answers "what is the fleet doing"
with silence is worse than no frontier, because the silence reads as an answer.
So the deliverable is a path that cannot be skipped, not a sync anyone has to maintain.
What changed
The ticket is filed by the dispatch and closed by the cleanup.
bin/fm-spawn.shcallsfm-tracker.sh syncon every crewmate and scout launch. That is theone path a dispatch cannot route around.
bin/fm-teardown.shcallsfm-tracker.sh completeon every cleanup, after every refusal haspassed and before the task's durable records are removed, so the outcome is read rather than
guessed.
config/tracker-reposmaps a project to the repository its tickets live in, one line pertracker,
<name>[,<alias>...] <owner/repo>. The name list is the project's alias set and ismatched against both the dispatched project directory and a task row's
(repo: <name>), becausedata/projects.mdalready registers a project under more than one spelling(
video-editing-pilotandvideo_editing_pilotare one project). Local, gitignored, inheritedinto secondmate homes.
syncreconciles the whole open queue, not just the dispatched task, so the frontier showswhat is queued and blocked as well as what is claimed. It reads
data/backlog.mddirectly, soit works under either backlog backend and a broken
tasks-axicannot stop a dispatch.Deliberately skipped:
kind: captainrows (a decision is not a task and has its own type withits own answerable-cold contract),
hold-kind: futurerows (parked rows are parked FROM thecaptain as much as from the fleet, so putting one on a board they read is re-raising it), and
hold-kind: externalrows (not work).Blocking edges are the queryable form. The task list states a dependency as
blocked-by: <task-id>in the row title.synclifts that out, resolves it to the blocker's ownticket, and emits the task-list reference GitHub turns into a real
trackedIssuesedge. Rows areordered so a blocker's ticket exists before the edge that names it - a reference to a number that
does not exist yet is an entry GitHub never resolves, which is the same defect arriving through
ordering instead of wording.
Nothing here can stop work. An unconfigured project, an absent
gh, an unauthenticatedgh,an unreachable GitHub and a hung connection all report the ticket they could not file and exit 0.
The call is time-bounded on both the dispatch and the cleanup side, because a non-zero exit does
not cover a connection that hangs instead of failing. A dispatch that dies because GitHub is down
is a worse defect than the missing ticket this exists to prevent.
The three known unknowns, answered
Spawn, not intake. Intake is where the task is really decided, and that is the argument for
it - but intake is firstmate's judgement, not a code path, and the defect being fixed is
precisely that firstmate did not remember. Spawn is the one path an ordinary dispatch cannot
route around, which is the whole difference between a mechanism and a discipline. The cost is
that a task decided but never dispatched has no ticket;
syncreconciles the whole queue ratherthan only the dispatched task, so that cost is paid back on the next dispatch to the same
project.
A teardown without a ship closes the ticket as NOT PLANNED. Left open, it reports READY on
the frontier forever with nobody working it, which reads as queued work - the confident wrong
answer this layer exists to prevent. Closed as not planned it keeps the record, renders
differently from a completed close, and carries its reason. If the work is dispatched again the
next sync reopens that same ticket rather than filing a second one. A scout report, a PR, and a
local-only landing all close as completed.
config/captain-githubis now load-bearing and is still unset - report only, unchanged.The fleet authenticates as the captain's own account, so with no captain login configured every
assignment reads as a claim. That was harmless while nothing was assigned. It is not harmless
now: three tickets are assigned to
prajwal-395andfrontierreports all three as CLAIMED.Setting
config/captain-githubtoprajwal-395would invert that - every agent claim would thenreport as a wait ON the captain, which is worse. The real fix is a separate machine identity for
the fleet, which is a change to how the fleet authenticates and belongs to the captain. Until
then the CLAIMED section means "an agent holds this" and a genuine wait on the captain is not
distinguishable from it.
frontiersays so in its closing note.The design this got wrong on its first live run, and the correction
The first version had the writer own the task ticket body in full: simple, safe, and always
convergent, with no merge to do. Its first real run filed duplicates of two task tickets that had
already been written by hand (#171, #172) - they carried no binding, so nothing could recognise
them. Those hand-written bodies turned out to hold the measurements, the captain's own words, and
the definition of done for that work, and a convergent rewrite would have deleted every line of
it.
A mirror that destroys what someone wrote into it is not a mirror. The writer now owns exactly
two things in a body - the marker line binding it to a task, and the canonical blocked-by section -
and preserves everything else verbatim. The separate rule that nothing the caller supplies is
ever composed into a body still holds and is why the summary is the TITLE: firstmate's own nouns
include "blocked on" and "depends on", so a summary passed through as a body would make a dispatch
fail to get a ticket because of how it happened to be worded, and a brief carries private strategy
that an issue body makes public.
task-open --adopt <n>binds an existing ticket to a task, by number and never by title, becausematching a ticket to a task by title is a guess and a guess here attaches a task's whole future to
a ticket about something else. #171 and #172 were adopted with it; the duplicates were closed
naming what they duplicate.
One rendering defect found along the way
frontier's CLAIMED section ran every entry onto the end of the previous one. It had neverrendered more than one row before, because until now nothing was ever claimed - so the section
that reports what the fleet is working on was unreadable exactly when the fleet was working on
more than one thing. Fixed, with a regression test.
The frontier, live
Backfilled once against the real queue, then read back:
Seven task tickets, three claimed by live workers, one blocked on another task's ticket by a real
queryable edge. Before this change the same command printed one destination and five unknowns.
validate, before and after
Malformed count went 19 -> 20. None of the new tickets are in it. The malformed set is
#142-#151 (the pre-existing [EXPERIMENT] fixtures), #162-#164, and #169. #169 was filed by hand
during this task, after the 19 was measured, and is orphaned for the same reason the others are.
Every ticket this change created or adopted (#170-#179) is attached under destination #156 and
carries its type as a label. Filing an orphan is refused rather than worked around: when a
repository has no single open destination, no task ticket is filed at all, because an orphan is
exactly what
validateexists to report and creating one to avoid an error message would trade aloud failure for a quiet wrong answer.
Tests
tests/fm-tracker-task-lifecycle.test.sh(24 cases) and three new cases intests/fm-teardown.test.sh. The load-bearing ones drive the real scripts, not the tracker alone:bin/fm-spawn.shdispatch files the ticket without being asked;bin/fm-teardown.shcleanup closes it and records its PR link;launched and the task record still cleared;
first;
rewrites it;
A second defect the first CI run exposed
The changed-file selection ran 78 scripts green and CI still failed on one it had not selected:
tests/fm-gotmp.test.sh, which drives the real teardown against a fixture that symlinks exactlythe libraries teardown sources. Adding the ticket close made teardown source one more library,
which that fixture did not have. The fixture is fixed - and it now symlinks
fm-tracker.shitself rather than stubbing it, so it exercises the real unconfigured-project path, which is the
behavior teardown depends on.
The reason the selection missed it is worth its own fix:
bin/fm-teardown.shmaps to thepr-forgefamily, and that suite is classifiedsession-bootstrap, so no teardown change hasever selected the teardown suite that lives outside its family.
bin/fm-teardown.shnow names itexplicitly, the way the tracker mapping already handles the same problem. The selection goes
78 -> 82 scripts and includes it.
One step this PR cannot take
config/is gitignored and this worker does not write outside its own copy, so the live homestill needs its one-line mapping before a dispatch files anything:
Until that exists, every dispatch reports "no tracker repository is configured" and proceeds
normally. The backfill above was run against the live queue with that mapping supplied out of
band, so the frontier is already true.