Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions .agents/skills/fleet-queue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ Three answers, and the third is not the second:
| it is not, or not from this branch | **leaves the task OPEN** and says so, loudly |
| could not run | closes the task, and says the check could not run |

"Could not run" is `gh` absent, no network, a pull request it cannot read, or a
"Could not run" is the forge CLI absent, no network, a pull request it cannot read, or a
base branch this machine cannot see. That must never read as a pass or a fail —
CI and an offline laptop both still have to collect. `queue.sh show <ref>`
prints the method and the verdict, so both survive the scrollback.
Expand Down Expand Up @@ -509,7 +509,7 @@ So a task gets a state AFTER `done`:
| `abandoned` | the pull request was closed unmerged | released; the work is NOT on main |
| `stuck` / `failed` | the worker gave up | **kept** — that session is the evidence, and you decide |

`landed` comes from asking `gh`, never from a worker claiming it, so it works
`landed` comes from asking the forge, never from a worker claiming it, so it works
long after the session is gone. **Blockers clear on `landed`**, not on `done`
— a dependent task waits for the code to actually be on `main`, which is the
same bug in its other form: a task collected `shipped` once released its
Expand Down Expand Up @@ -689,11 +689,11 @@ opened it.
first pull request its worker reported. #25 was a *second* pull request from a
task whose artifact still pointed at the already-merged #23, so a shepherd
reading artifacts could not see it and the unattended pass would never have
merged it; a PR opened outside the queue was invisible the same way. So it runs
`gh pr list --state open` against every repo the queue's tasks name, and each
open pull request gets exactly one of these:
merged it; a PR opened outside the queue was invisible the same way. So it asks
the forge for every open change request against every repo the queue's tasks
name, and each open pull request gets exactly one of these:

| What `gh` says | What happens |
| What the forge says | What happens |
|---|---|
| the head branch is in someone else's fork | reported, never merged, **never given an agent** |
| `mergeable: CONFLICTING` | a fixer is dispatched to rebase |
Expand Down Expand Up @@ -736,15 +736,18 @@ Three things it will not do, and they are what make it safe to run:
`working` or `blocked` is left alone. So is one whose state is merely
*observed* — `running`, `uncovered`, `unreported` are not the agent saying it
is at rest (`thurbox-session` §4a).
- **It will not guess.** No `gh`, no network, no thurbox: it says what it could
not determine and carries on. A PR it could not read is never called broken
- **It will not guess.** No forge, no network, no thurbox: it says what it
could not determine and carries on. A PR it could not read is never called broken
and never called ready.

**On merging, which is the part that runs unattended.** `Thurbeen/fleet` is
public and has a fork, so "merge every open PR on a timer" has to survive a
stranger opening one. Fleet merges only in the repos on `AUTO_MERGE_REPOS` in
`scripts/lib/queue.py` — `Thurbeen/fleet` — and only when **all** of these
hold:
`scripts/lib/queue.py` — `github.com/Thurbeen/fleet` and
`github.com/Thurbeen/thurview` — and only when **all** of these hold. Entries
there are HOST-QUALIFIED and one that names no forge is refused rather than
matched: `Thurbeen/fleet` on github.com and `Thurbeen/fleet` on a self-hosted
instance are not the same repository.

- **The head branch is in that repository**, not a fork. A stranger cannot
create a branch here, so this is the one claim about a pull request that
Expand Down
17 changes: 15 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ names every path and the reason for each.
directory your shell is in**, so a second clone of this repo cannot
silently fork it: `topic add` and `add` refuse there, everything else
warns, and `queue.sh root` names the directory in use.
- `scripts/lib/forge.py` — the FORGE seam. Everything fleet knows about a
change request — a pull request on GitHub, a merge request on GitLab — it
asks this module for; `scripts/lib/queue.py` runs no forge CLI itself and
builds no forge URL. GitHub, through `gh`, is the one implementation shipped,
and it is a CONFIGURATION and not an assumption. The file's own header owns
the interface and how to add another. Two things follow: a repository is
identified by HOST plus path (`github.com/Thurbeen/fleet`), because a bare
`owner/repo` names two different repositories once two forges exist; and
`queue-selftest.sh` drives `collect`, the landing check and `shepherd`
through a second, fake forge with no network and no `gh` behind it, which is
what keeps the seam honest rather than merely asserted.
- `orchestration/reconcile/` — the reconciler's runtime state: its supervisor's
pid, the heartbeat proving its loop is ticking, its log, the advisory `nudge`
flag and the `down` flag. Written by `./scripts/reconcile.sh` and created on
Expand Down Expand Up @@ -141,8 +152,10 @@ The loop, driven by `./scripts/queue.sh`:
the queue's tasks name, not the tasks' recorded artifacts. A PR is linked
back by artifact or head branch; an unlinked one is
still classified and merged, it just has no session to fix it. It merges
only in the repos `AUTO_MERGE_REPOS` names in `scripts/lib/queue.py`, and
only for a PR whose head branch is in that repo, opened by someone who can
only in the repos `AUTO_MERGE_REPOS` names in `scripts/lib/queue.py` — each
entry host-qualified, and one that names no forge refused rather than
matched — and only for a PR whose head branch is in that repo, opened by
someone who can
push there, carrying a `no-mistakes` attestation for its **current** head.
That attestation gate is the one thing the declared publish
method moves: a task that was declared `no-mistakes` and carries none gets a
Expand Down
7 changes: 5 additions & 2 deletions FLEET.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ YAML by hand. Nothing to push — the map is gitignored.
recorded artifacts, dispatches a fixer for one that conflicts, fails a
check, was reviewed with changes requested, or was declared `no-mistakes`
and carries no attestation for its current head, and squash-merges one that
clears every gate in the repos `AUTO_MERGE_REPOS` allows. It writes down
clears every gate in the repos `AUTO_MERGE_REPOS` allows — entries there
name their forge (`github.com/Thurbeen/fleet`), because a bare `owner/repo`
is two different repositories once two forges are configured. It writes down
what it saw either way, so a task's record says `checks-running` or
`unattested` and not just `shipped`.
7. Review the PRs; the operator merges every one `shepherd` did not. Sessions
Expand Down Expand Up @@ -240,7 +242,8 @@ The register never costs a fact. Where the two pull against each other, the
fact wins:

- **Uncertainty is a state word, not a hedge.** `waiting`, `not listed`,
`unavailable — gh not found`. Never "probably", never "should be" — and
`unavailable — gh not found` (that word is the GitHub adapter's own; another
forge names its own tool). Never "probably", never "should be" — and
never a register word standing in for one. `holding, awaiting telemetry`
in place of `unavailable — gh not found` has broken this section, not
styled it.
Expand Down
5 changes: 3 additions & 2 deletions orchestration/queue/POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,9 @@ The default for every task here is the frontmatter at the top of this file.
request title becomes the commit on `main`. Write the title accordingly.
- **You do not merge.** Opening it is where your work ends. `queue.sh
shepherd` may later merge it for you in the repos its `AUTO_MERGE_REPOS`
allowlist names, but only once your pull request clears its gates — never
merge it yourself in the meantime.
allowlist names (host-qualified, as in `github.com/owner/repo`), but only
once your pull request clears its gates — never merge it yourself in the
meantime.

## Reporting back — write a file, do not send mail

Expand Down
2 changes: 1 addition & 1 deletion orchestration/queue/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ branch (carrying a `no-mistakes` attestation for its head, for that method),
git for a commit that reached the base branch. A task whose artifact is not
there is reported and left OPEN, because "use the pipeline" is an instruction
about a method and a method leaves no trace anyone can read. A check that could
not run — no `gh`, no network, a base branch this machine cannot see — says
not run — no forge CLI, no network, a base branch this machine cannot see — says
exactly that and is never counted as either verdict. `collect
--allow-unverified` closes a flagged task once you have read that artifact
yourself. `queue.sh show` prints the method, the verdict and the publish state
Expand Down
98 changes: 39 additions & 59 deletions scripts/lib/fleet_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
# WHY THIS EXISTS. Answering "where are we?" used to cost three to five
# commands spread over three checkouts and four tools: a `git status` and a
# `git log` per checkout, `queue.sh list`, `queue.sh plan`, `thurbox-cli
# session list`, `gh pr list`. Most of a long session's tool
# session list`, the forge's own list. Most of a long session's tool
# calls were situational awareness rather than work, and every one of them cost
# a round trip and a piece of the context window. This is those calls, folded
# into one screen the lead can afford to run reflexively.
#
# THE ONE RULE: DEGRADE, NEVER FAIL. No network, no `gh`, no thurbox, no
# THE ONE RULE: DEGRADE, NEVER FAIL. No network, no forge CLI, no thurbox, no
# queue — each of those costs exactly its own section, which then
# says what it could not determine and why. Every probe funnels through run(),
# which converts every way a subprocess can go wrong into a reason string, and
Expand Down Expand Up @@ -55,6 +55,11 @@ def _load_queue():

fleetqueue = _load_queue()

# The forge seam, which queue.py has already loaded and keyed in sys.modules —
# so this is the SAME module object and therefore the same registry, not a
# second opinion about which forges are configured.
forge = fleetqueue.forge

# The checkout this file ships in, found from the file rather than from the
# working directory — the lead may run this from anywhere.
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
Expand Down Expand Up @@ -242,65 +247,48 @@ def probe_sessions(tasks: list) -> dict:
return sec


# --- pull requests -----------------------------------------------------------
# --- change requests ---------------------------------------------------------

CHECK_FAIL = {"FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR"}
CHECK_PASS = {"SUCCESS", "NEUTRAL", "SKIPPED"}

def rollup(checks) -> str:
"""One word for a change request's checks: passing, failing, pending, or none.

def rollup(entries) -> str:
"""One word for a PR's checks: passing, failing, pending, or none."""
if not isinstance(entries, list) or not entries:
`cancelled` counts as failing here — this line's own long-standing
reading, separate from the shepherd's, which is why the forge hands back
`cancelled` as its own verdict rather than pre-deciding for either.
"""
if not checks:
return "none"
failing = pending = 0
for e in entries:
if not isinstance(e, dict):
continue
verdict = (e.get("conclusion") or e.get("state") or "").upper()
if verdict in CHECK_FAIL:
failing += 1
elif verdict in CHECK_PASS:
continue
else:
pending += 1
if failing:
if any(c.verdict in ("failed", "cancelled") for c in checks):
return "failing"
return "pending" if pending else "passing"


def pr_slug(url: str) -> str:
"""`Thurbeen/fleet#13` out of the URL gh already handed back.

Deliberately not a second `gh repo view`: the identity is in the artifact,
and a status command should not spend an API call to pretty-print a name.
"""
parts = [p for p in str(url).split("/") if p]
if len(parts) >= 4 and parts[-2] == "pull":
return f"{parts[-4]}/{parts[-3]}#{parts[-1]}"
return str(url)
return "pending" if any(c.verdict == "pending" for c in checks) else "passing"


def probe_prs(tasks: list) -> dict:
"""Open PRs in the repos this queue is working in, matched back to tasks.
"""Open change requests in the repos this queue works in, matched to tasks.

Matched by recorded artifact first, then by branch — so a PR a worker
opened and has not reported yet still shows up, which is exactly the gap
between "the worker should have opened a PR" and the artifact itself.
Matched by recorded artifact first, then by branch — so one a worker opened
and has not reported yet still shows up, which is exactly the gap between
"the worker should have opened a pull request" and the artifact itself.

One `gh pr list` per distinct repo, not one per task.
One list call per distinct repo, not one per task. Asked of the CHECKOUT
and not of a repository id: this command has a path on disk and no identity
for it, and a directory that is not a git repository at all still has to
produce a sentence rather than an empty list that reads as "nothing is
open". `scripts/lib/forge.py` decides which forge answers.
"""
sec: dict = {"unavailable": None, "prs": [], "errors": []}
# A remote task's `repo` is a path on its host, so `gh -C` here would ask
# the wrong filesystem and report "no such directory" about a checkout that
# exists. Skipped and SAID, rather than turned into an error that reads as
# A remote task's `repo` is a path on its host, so asking a forge CLI here
# would ask the wrong filesystem and report "no such directory" about a
# checkout that exists. Skipped and SAID, rather than turned into an error that reads as
# a broken record.
live = [t for t in tasks if t.get("repo") and t.get("state") != "queued"]
# `kind` is what the headline counts. `unread` is a repo this sweep tried
# and failed to read — a hole in the finding. `skipped` is one it never
# swept, which is a different sentence and must not read as a failure.
remote = [
{"repo": f"(on host {h})", "kind": "skipped",
"reason": "runs on a remote host; its pull requests are read by "
"reason": "runs on a remote host; its change requests are read by "
"`queue.sh shepherd`, which asks the forge and not a checkout"}
for h in sorted({t["host"] for t in live if t.get("host")})
]
Expand All @@ -319,41 +307,33 @@ def probe_prs(tasks: list) -> dict:
sec["errors"].append({"repo": repo, "kind": "unread",
"reason": "no such directory"})
continue
doc, why = run_json(
["gh", "pr", "list", "--state", "open", "--limit", "50", "--json",
"number,url,title,headRefName,state,statusCheckRollup"],
cwd=repo,
timeout=20,
)
crs, why = forge.open_change_requests_in_checkout(repo)
if why:
reasons.append(why)
sec["errors"].append({"repo": repo, "kind": "unread", "reason": why})
continue
for pr in doc if isinstance(doc, list) else []:
if not isinstance(pr, dict):
continue
url = str(pr.get("url") or "")
head = pr.get("headRefName")
for cr in crs:
url, head = cr.url, cr.head_branch
owner = next(
(t for t in owners if t.get("artifact") and str(t["artifact"]).rstrip("/") == url.rstrip("/")),
None,
) or next((t for t in owners if head and t.get("branch") == head), None)
if owner is None:
continue # somebody else's PR in the same repo
continue # somebody else's change request in the same repo
sec["prs"].append(
{
"ref": owner["ref"],
"repo": repo,
"slug": pr_slug(url),
"number": pr.get("number"),
"slug": cr.name,
"number": cr.number,
"url": url,
"title": pr.get("title"),
"title": cr.title,
"branch": head,
"checks": rollup(pr.get("statusCheckRollup")),
"checks": rollup(cr.checks),
}
)

# Every repo failed the same way — `gh` absent, most likely — so that is
# Every repo failed the same way — no forge CLI, most likely — so that is
# the section's story rather than a list of identical per-repo errors.
if reasons and len(sec["errors"]) == len(repos) and len(set(reasons)) == 1:
sec["unavailable"] = reasons[0]
Expand Down
Loading