Skip to content

feat(tracker): a dispatch files its GitHub task ticket and a cleanup closes it - #72

Merged
prajwal-395 merged 3 commits into
mainfrom
fm/fm-github-task-sync
Aug 26, 2026
Merged

prajwal-395 merged 3 commits into
mainfrom
fm/fm-github-task-sync

Conversation

@prajwal-395

@prajwal-395 prajwal-395 commented Aug 26, 2026 •

Copy link
Copy Markdown
Owner

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 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. Nothing was wrong with the tool - filing a ticket 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 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.sh calls fm-tracker.sh sync on every crewmate and scout launch. That is the
    one path a dispatch cannot route around.
  • bin/fm-teardown.sh calls fm-tracker.sh complete on every cleanup, after every refusal has
    passed and before the task's durable records are removed, so the outcome is read rather than
    guessed.
  • A secondmate spawn 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, one line per
tracker, <name>[,<alias>...] <owner/repo>. The name list is the project's alias set and is
matched against both the dispatched project directory and a task row's (repo: <name>), because
data/projects.md already registers a project under more than one spelling
(video-editing-pilot and video_editing_pilot are one project). Local, gitignored, inherited
into secondmate homes.

sync reconciles the whole open queue, not just the dispatched task, so the frontier shows
what is queued and blocked as well as what is claimed. It reads data/backlog.md directly, so
it works under either backlog backend and a broken tasks-axi cannot stop a dispatch.
Deliberately skipped: kind: captain rows (a decision is not a task and has its own type with
its own answerable-cold contract), hold-kind: future rows (parked rows are parked FROM the
captain as much as from the fleet, so putting one on a board they read is re-raising it), and
hold-kind: external rows (not work).

Blocking edges are the queryable form. The task list states a dependency as
blocked-by: <task-id> in the row title. sync lifts that out, resolves it to the blocker's own
ticket, and emits the task-list reference GitHub turns into a real trackedIssues edge. Rows are
ordered 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 unauthenticated gh,
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; sync reconciles the whole queue rather
than 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-github is 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-395 and frontier reports all three as CLAIMED.
Setting config/captain-github to prajwal-395 would invert that - every agent claim would then
report 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. frontier says 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, because
matching 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 never
rendered 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:

frontier: prajwal-395/video_editing_pilot

READY (open, unclaimed, no open blocker):
  #156   [destination] The pipeline edits 001's footage well enough to post untouched
  #157   [unknown    ] Does the model driving the pipeline know how to edit at all?
  #158   [unknown    ] Does clearing the seven craft properties actually produce something postable?
  #176   [task       ] Generate variants along the quality-determining parameters and let the captain choose, then work backward to values
  #178   [task       ] Decisions are asked for and silently dropped before the manifest, where the repo's own reader discipline does not reach
  #179   [task       ] CI excludes every heavy dependency, so the working paths behind prosody, torch, sam2 and whisperx have no automated coverage at all
  #182   [task       ] Two ingest steps were written and never added to the DAG

BLOCKED (open, waiting on something - a blocker, or the captain):
  #162   [unknown    ] Can the pipeline mask and track a subject with SAM?
            waits on #156 - the destination itself, unclaimed
  #163   [unknown    ] Is a depth map usable on this footage, and what can it drive?
            waits on #156 - the destination itself, unclaimed
  #164   [unknown    ] What does Higgsfield actually expose, and can DaVinci take it unattended?
            waits on #156 - the destination itself, unclaimed
  #169   [unknown    ] Is phase 1 a separable product, and is this a creative suite rather than a pipeline?
            waits on #156 - the destination itself, unclaimed
  #177   [task       ] Hand-cut one video to the captain's standard, then enumerate what the pipeline cannot reproduce and why
            waits on #176 - unfinished work, unclaimed

CLAIMED (excluded from the frontier - an agent assignment is the claim):
  #171   [task       ] Empty folders make the output lie, and the input side was never standardised
            claimed by prajwal-395
  #173   [task       ] How does Twelve Labs actually work, and what does it mean for our phase 1
            claimed by prajwal-395

note: no captain login is configured, so every assignment reads as a claim.
      put the captain GitHub login in config/captain-github to tell a wait from a claim.

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

validate: prajwal-395/video_editing_pilot
  #142 missing type label - type would have to be guessed from the title: [EXPERIMENT] Destination: 001 is a video the captain judges good
  #142 orphaned - no parent, so it hangs off no destination and never rolls up
  #143 missing type label - type would have to be guessed from the title: [EXPERIMENT] DECISION: which caption rules bind 001?
  #144 missing type label - type would have to be guessed from the title: [EXPERIMENT] DECISION: does the house grain belong on this video?
  #145 missing type label - type would have to be guessed from the title: [EXPERIMENT] TASK: apply the ruled caption style to 001
  #145 line 2 uses a non-canonical blocked-by heading for #143 - the edge
        resolves, but this was not written by fm-tracker.sh add
  #146 missing type label - type would have to be guessed from the title: [EXPERIMENT] TASK: set the house look to the ruled values
  #146 line 2 uses a non-canonical blocked-by heading for #144 - the edge
        resolves, but this was not written by fm-tracker.sh add
  #147 missing type label - type would have to be guessed from the title: [EXPERIMENT] TASK: write the checkable half as tests
  #147 line 1 states a blocker in prose but names no issue - a human reads it
        as authoritative and nothing queries it: **Blocked by:** nothing - this is the frontier.
  #149 has no "## Context" section with anything under it, so answering it starts with research
  #149 has fewer than two options under "## Options", and one option is not a choice
  #149 has no "## Recommendation" section with anything under it, so the answer has to be built from scratch
  #149 has no "## Or something else" section - an options list with no way out pushes
        the reader toward the nearest listed answer
  #151 line 1 names #150 as a blocker with no resolved edge - invisible to
        the frontier query, so this ticket reports READY while blocked
  #151 orphaned - no parent, so it hangs off no destination and never rolls up
  #162 orphaned - no parent, so it hangs off no destination and never rolls up
  #163 orphaned - no parent, so it hangs off no destination and never rolls up
  #164 orphaned - no parent, so it hangs off no destination and never rolls up
  #169 orphaned - no parent, so it hangs off no destination and never rolls up

20 malformed ticket(s). fm-tracker.sh add writes this structure correctly;
hand-edits do not.

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 validate exists to report and creating one to avoid an error message would trade a
loud failure for a quiet wrong answer.

Tests

tests/fm-tracker-task-lifecycle.test.sh (24 cases) and three new cases in
tests/fm-teardown.test.sh. The load-bearing ones drive the real scripts, not the tracker alone:

  • a real bin/fm-spawn.sh dispatch files the ticket without being asked;
  • a real bin/fm-teardown.sh cleanup closes it and records its PR link;
  • a real cleanup with no PR closes it as not planned;
  • a real dispatch and a real cleanup both finish with GitHub unreachable, and the worker is still
    launched and the task record still cleared;
  • a backlog dependency becomes a task-list edge and never prose, with the blocker's ticket created
    first;
  • a hand-filed ticket adopted by number keeps its body, and a later sync neither duplicates it nor
    rewrites it;
  • a sync that changes nothing writes nothing;
  • captain-held, parked and external rows are not mirrored.

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 exactly
the 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.sh
itself 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.sh maps to the
pr-forge family, and that suite is classified session-bootstrap, so no teardown change has
ever selected the teardown suite that lives outside its family. bin/fm-teardown.sh now names it
explicitly, 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 home
still needs its one-line mapping before a dispatch files anything:

echo 'video-editing-pilot,video_editing_pilot prajwal-395/video_editing_pilot' > config/tracker-repos

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.

…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.
@prajwal-395
prajwal-395 merged commit c4aae6d into main Aug 26, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant