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
6 changes: 4 additions & 2 deletions adapters/pull.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,10 @@ def unchanged(transport, pull: dict) -> bool:
which no trial has asked yet. So a snapshot is never of a pull request that changed between its first read and
its last; one that did is read again at the next pass."""
again = read(transport, pull["base"]["repo"]["full_name"], pull["number"])
return (_same(again, pull) and isinstance(pull.get("updated_at"), str)
and again.get("updated_at") == pull["updated_at"])
if not (isinstance(pull.get("updated_at"), str) and isinstance(again.get("updated_at"), str)):
# A time that cannot be read is no move: the read GitHub did not answer, not one that earns a pass's grace (#658).
raise Unanswered("the pull request's updated_at cannot be read")
return _same(again, pull) and again["updated_at"] == pull["updated_at"]


def _account(actor) -> tuple:
Expand Down
2 changes: 1 addition & 1 deletion controller/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Not built yet, by the stories that hold their rules: relay.py, backpressure.py,
| The pass record holds what the Kernel was given, and replays every Decision | `tests/test_controller.py::test_the_pass_record_holds_what_the_kernel_was_given_and_replays_it` |
| A Run id is minted for an admission, an approval, or a queue act once its workflow is named | `tests/test_controller.py::test_a_queue_act_gets_a_run_id_and_a_dispatch_once_its_workflow_is_named` |
| A Run is dispatched by its run-name; a capacity fault defers model Runs for its window | `tests/test_controller.py::test_the_runs_to_dispatch_are_named_by_their_run_names`, `tests/test_controller.py::test_a_capacity_fault_defers_model_runs_for_its_window` |
| An item whose facts are not facts, or listed twice, is left out, as one alert; any other is decided as without it | `tests/test_controller.py::test_an_item_whose_facts_are_not_facts_is_left_out_and_raises_one_alert` |
| An item whose facts are not facts, or listed twice, is left out as one alert; a pull request moved once, with none; the rest are decided | `tests/test_controller.py::test_a_pull_request_that_moved_while_read_is_deferred_once_with_no_alert` |
| An issue is asked of the issues' Kernel, under its own Lifecycle, and its repo's Pause is read | `tests/test_controller.py::test_an_issue_is_asked_of_the_issues_kernel_with_its_own_lifecycle` |
| A record that cannot validate is refused whole, and nothing is dispatched | `tests/test_controller.py::test_a_record_that_cannot_validate_is_refused_and_nothing_is_dispatched` |
| A Pause on the org or a repo is a Pause; a value but exact `true`, exact `false` or none is unreadable | `tests/test_controller.py::test_a_pause_on_the_org_or_the_repo_is_a_pause_and_any_other_value_is_unreadable` |
Expand Down
25 changes: 18 additions & 7 deletions controller/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
effects step holds to it, and the view's three files under `view/`.

The Release is the one tag `factory/vX.Y.Z` on the commit the pass checked out (`harness/steps.release`), read before any key. A snapshot that cannot be read leaves its pull
request out of the pass, by its item, as one alert. A list of open pull requests not read whole, a Release, a key or
request out of the pass, by its item and the code of why, as one alert; one that moved while it was read, and had not in
the last pass, is deferred with no alert and its writes are owed to the next pass (#658). A list of open pull requests not read whole, a Release, a key or
a run id that cannot be read, or a pass `pass_.py` refuses, writes nothing, so that nothing is dispatched; the step
prints one code and exits 1, and nothing it prints is GitHub's text."""
from __future__ import annotations
Expand All @@ -30,7 +31,7 @@
from adapters.facts import accounts_of as accounts, dispatcher_of as dispatcher, opener_of as opener, queue_of
from adapters.github import Live, Unanswered
from controller import configuration as configured, pass_, reads as reads_, reconcile, writes as writes_
from controller.shared import Refused
from controller.shared import Refused, key as page
from harness import steps

ROOT = pathlib.Path(__file__).resolve().parent.parent
Expand Down Expand Up @@ -92,11 +93,12 @@ def dated(answer) -> list:
return [{"date": at["date"], "age": at["age"]}] if at else []


def writes_seen(transport, config: dict, numbers: list, kept: dict, before, since: str, clock) -> tuple:
def writes_seen(transport, config: dict, numbers: list, kept: dict, before, since: str, clock, owed=()) -> tuple:
"""`(seen/1, the pull requests read short)`: what each Factory identity wrote on what this pass served, or on what the
pass before served or could not read and has left (decision 0061). What a snapshot could not read is short, and one that
is short is read again by the next pass, so that nothing leaves unread. So is a pass with no records before it to say
what has left. Nothing is guessed."""
what has left. Nothing is guessed. One `owed`, a pull request that moved for the first time, is not read: it is owed
to the next pass, as one read short is, and leaves this reconciliation whole (#658)."""
controller = config["controller"]
home, accounts_held = controller["home"]["repo"], controller["accounts"]
identities, _ = seen_.identities(accounts_held)
Expand All @@ -105,6 +107,9 @@ def writes_seen(transport, config: dict, numbers: list, kept: dict, before, sinc
served = {e["facts"]["item"]["number"] for e in last} | set(reconcile.owed(before))
parts, unread = [], []
for number, held in [(n, kept.get(n)) for n in numbers] + [(n, seen_.LEFT) for n in sorted(served - set(numbers))]:
if number in owed:
unread.append(number)
continue
try:
part = seen_.read(transport, {"repo": home, "type": "pull_request", "number": number}, held, identities, apps,
since)
Expand Down Expand Up @@ -176,11 +181,17 @@ def read(transport, config: dict, run_id: str, previous, now: str, clock, starte
pulls.append({"facts": facts, "title": title, "head": head})
kept[number] = held
facts_reads += dated(through.first)
except Unanswered:
pulls.append({"facts": {"item": {"repo": home, "type": "pull_request", "number": number}}, "title": None})
except Unanswered as err:
# Its code goes in the record: one that moved while it was read is not one GitHub did not answer (#658).
pulls.append({"facts": {"item": {"repo": home, "type": "pull_request", "number": number}}, "title": None,
"unread": reads_.MOVED if isinstance(err, facts_.Moved) else reads_.UNANSWERED})
before = pass_.earlier(reads_.previous_records(previous), now)
since = utc(lambda: clock() - WINDOW - controller["not_run_seconds"])
seen, unread = writes_seen(transport, config, numbers, kept, before, since, clock)
# A pull request that moved for the first time is owed to the next pass's reconciliation, not read short here.
left = [e for e in pulls if "unread" in e]
graced = pass_.moved_once([e["facts"]["item"] for e in left], [e["unread"] for e in left], before)
owed = {e["facts"]["item"]["number"] for e in left if page(e["facts"]["item"]) in graced}
seen, unread = writes_seen(transport, config, numbers, kept, before, since, clock, owed)
return {"started": started, "pause": pause_.read(transport, home.split("/")[0], [home]),
"records": runs, "pull_requests": pulls, "issues": [], "facts_reads": facts_reads, "usage": {},
"seen": seen, "unread": unread, "effects": recorded_effects(transport, config, runs, int(run_id), before, since)}
Expand Down
27 changes: 23 additions & 4 deletions controller/pass_.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,20 @@ def status_head(entry: dict, kernel: dict) -> dict:
return {} if pull_request.left_alone(facts, kernel) else {"head": entry["head"]}


def moved_once(left_out: list, why: list, before) -> set:
"""The pages of the items left out because they moved while read that the last pass record read and did not leave
out as moved: each is deferred with no alert, once, and its writes are owed to the next pass (#658). It is derived
from that record, never kept: a last record that cannot be read, or holds no `left_out`, cannot say, so each move
counts as a second."""
last = before[-1] if isinstance(before, list) and before else None
held = last.get("left_out") if isinstance(last, dict) else None
if not isinstance(held, list):
return set()
again = {key(e["item"]) for e in held if e["code"] == reads_.MOVED and e["item"] is not None}
return {key(i) for i, code in zip(left_out, why) if code == reads_.MOVED and reads_.item(i) is not None
and i["type"] == "pull_request" and key(i) not in again}


def run(reads: dict, config: dict, now: str, previous, mint) -> dict:
"""One pass: the record, the Runs to dispatch, the acts, the view, the alerts and whether a successor starts."""
rules = reads_.configured(config["controller"])
Expand All @@ -270,9 +284,13 @@ def run(reads: dict, config: dict, now: str, previous, mint) -> dict:
previous, before = (None, []) if before is None else (before, before)
# Facts that are not facts, and an item listed twice, never reach the Kernel and no record could hold them: the
# item is left out of this pass, as one alert, and every other item is decided as it would be without it.
pulls, left_out = reads_.items(copy.deepcopy(reads.get("pull_requests")), "facts")
issues, also = reads_.items(copy.deepcopy(reads.get("issues")), "issue-facts")
left_out += also
pulls, left_out, why = reads_.items(copy.deepcopy(reads.get("pull_requests")), "facts")
issues, also, why_also = reads_.items(copy.deepcopy(reads.get("issues")), "issue-facts")
left_out, why = left_out + also, why + why_also
# A pull request that moved while it was read, and had not in the last pass, is deferred with no alert: the pass its
# change starts reads it again. Any other left out, or one that moved twice in a row, is the alert (#658).
graced = moved_once(left_out, why, before)
alarmed = [i for i in left_out if reads_.item(i) is None or key(i) not in graced]
repos = {e["facts"]["item"]["repo"] for e in pulls + issues}

records = reads.get("records")
Expand Down Expand Up @@ -304,6 +322,7 @@ def asked(entries: list, decide, lifecycle: dict) -> list:
+ [dict(r, read="facts") if isinstance(r, dict) else r for r in reads.get("facts_reads") or []],
"pull_requests": asked(pulls, pull_request.decide, config["lifecycles"]["pull_request"]),
"issues": asked(issues, issue.decide, config["lifecycles"]["issue"]),
"left_out": [{"item": reads_.item(i), "code": code} for i, code in zip(left_out, why)],
"reconciliation": reconcile_.entries(found),
"reconciled": reconcile_.carried(before, reads.get("effects"), reads.get("seen"), now, reads.get("unread") or ())}
checked(record, "pass")
Expand Down Expand Up @@ -343,7 +362,7 @@ def gap(later: str, earlier_: str) -> bool:
due, names_due = credentials_.due(config.get("credentials"), now)
causes = automatic_causes(runs, now, rules["automatic_pause"]) if automatic is not None else []
alerts = health_.alerts([e["decision"]["reason"] for e in entries], context, capacity, causes,
bool(left_out), raised, unnamed, reconcile_.alerts(found), due,
bool(alarmed), raised, unnamed, reconcile_.alerts(found), due,
whole and never_started(runs, now, rules["automatic_pause"]))
decided = [e["facts"]["item"] for e in entries]
titles = {key(e["facts"]["item"]): e.get("title") if isinstance(e.get("title"), str) else ""
Expand Down
27 changes: 23 additions & 4 deletions controller/reads.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ def _read(entry) -> bool:


def items(entries, kind: str) -> tuple:
"""`(kept, left_out)`: the entries whose facts are facts of `kind`, and the item of each that is not, as one list.
An item listed twice is left out every time it is listed, so that one pass never decides it twice."""
"""`(kept, left_out, why)`: the entries whose facts are facts of `kind`, the item of each that is not, as one list,
and why each is left out, as `pass/1` keeps it (#658): listed twice; else the facts port's word for a read that
failed, the entry's `unread`; else facts that are not facts. An item listed twice is left out every time it is
listed, so that one pass never decides it twice."""
if not isinstance(entries, list):
# A list of items that could not be read is no evidence that there are none: a pass that wrote an empty feed
# from it would look healthy. It is refused, and the not-run alert reports it.
Expand All @@ -52,9 +54,26 @@ def items(entries, kind: str) -> tuple:
# An item listed twice is counted wherever it can be named, readable or not, so a bad copy leaves the good one out.
seen = [_named(e) for e in entries]
kept = [e for e in readable if seen.count(key(e["facts"]["item"])) == 1]
out = [e for e in entries if not any(e is k for k in kept)]
left_out = [e.get("facts", {}).get("item") if isinstance(e, dict) and isinstance(e.get("facts"), dict) else None
for e in entries if not any(e is k for k in kept)]
return kept, [i if isinstance(i, dict) else None for i in left_out]
for e in out]
why = [TWICE if _named(e) is not None and seen.count(_named(e)) > 1
else e["unread"] if isinstance(e, dict) and e.get("unread") in UNREAD else NOT_FACTS for e in out]
return kept, [i if isinstance(i, dict) else None for i in left_out], why


# Why an item is left out of a pass (#658): a read that moved while it was read, which earns one pass's grace; one GitHub
# did not answer; facts that are not facts; an item listed twice.
MOVED, UNANSWERED, NOT_FACTS, TWICE = "pull_moved", "facts_unanswered", "not_facts", "listed_twice"
UNREAD = (MOVED, UNANSWERED)


def item(found):
"""`found` if it is an item as `facts/1` spells one, which a pass record can keep; None otherwise."""
return found if (isinstance(found, dict) and set(found) == {"repo", "type", "number"}
and isinstance(found["repo"], str) and found["repo"].count("/") == 1
and "" not in found["repo"].split("/") and found["type"] in ("pull_request", "issue")
and type(found["number"]) is int and found["number"] >= 1) else None


def _named(entry):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# 0172. A pull request that moved while a pass read it is deferred once with no alert

Status: accepted. The Factory Builder's ruling of 2026-11-08 on issue 658, "Controller: a pull request that changes while a pass reads it raises facts_unreadable and reconciliation_unreadable, though Moved means read it again". From 2026-11-08 05:38Z, Factory alerts were opened seven times and closed within minutes. Each time, one pull request had merged, taken a commit or taken a comment less than a minute before the pass read it. The snapshot raised `Moved`, which means "read it again at the next pass". The entry took it as any read GitHub did not answer, so it raised `facts_unreadable`, and the item's writes, never read, left reconciliation short. It adds a grace pass and amends nothing else: decision 0056's tracking issue still opens on a pass's first cause.

## Decision

| What | Decided | Because |
|---|---|---|
| Why an item is left out | `pass/1` keeps `left_out`: each item the pass left out, in the order read, with its code. `pull_moved` is a snapshot that moved while it was read. `facts_unanswered` is any other read the facts port could not make. `not_facts` is facts that do not hold to their schema, and `listed_twice` an item listed twice, whatever its read said. The item is null where none can be named. A record written before it holds none | The cause is seen in the record, not inferred: which read failed, on which item |
| The grace pass | A pull request whose code is `pull_moved`, and which the last pass record read did not leave out as `pull_moved`, is deferred with no alert: no facts, no Decision and no act, as now, but not `facts_unreadable`. Its writes are not read in this pass. They are owed to the next, as a pull request read short is, so this reconciliation stays whole. The next pass reads it again: one a run's end starts, or the next scheduled one, at most twenty minutes on | A merge, a push or a comment during a read is the Factory working, not a fault. A person assigned to an issue that closes itself minutes later learns nothing from it |
| What still alerts at once | A second move in a row. Any other code. An issue left out as moved: the grace is a pull request's. A read whose `updated_at` cannot be read is no move: `adapters/pull.py` raises it as a read GitHub did not answer, `facts_unanswered`. A last record that cannot be read, that is not there, or that holds no `left_out`: it cannot say the move is a first, so the move counts as a second | Something that never settles still reaches a person, and only `Moved` earns the grace. A pass fails closed |
| Derived, never kept | Whether a move is a first is read from the last pass record at each pass, in `controller/pass_.py`'s `moved_once`. The entry asks the same of it to leave the owed writes unread | State is derived from facts and run records at every call, never stored |
| Not here | `adapters/checks.py` still raises a plain `Unanswered` when the head moves while the checks are read. That read shows as `facts_unanswered` and alerts at once. Making it a `Moved` would also change how the approving act classes it | The record's codes will show whether it happens. It is a change of its own |

## Why

The tracking issue is how the Factory's faults reach the Maintainer. Seven alerts in a day for pull requests that were only changing would teach him to ignore it. Deferring a moved pull request for one pass keeps the Kernel's rule: an unreadable fact defers. Raising it on the second move keeps the alert for what does not settle.

## Boundaries

| Boundary | Check |
|---|---|
| A pull request that moved once is deferred with no alert, and its record keeps `pull_moved`. One that moved twice in a row is the alert. A last record that left it out for another reason, left another item out as moved, or left out one it could not name, makes the move a first. An issue left out as moved is the alert. One read clean after a move is decided. Any other code is the alert at once, and so is a move after a last record that cannot be read, is not there or holds no `left_out`. An item listed twice is `listed_twice`, and one that names no item is kept as null | `tests/test_controller.py::test_a_pull_request_that_moved_while_read_is_deferred_once_with_no_alert`, `tests/test_controller.py::test_a_snapshot_that_moved_is_kept_by_its_code_and_its_writes_are_owed_once`, `controller/pass_.py`, `controller/reads.py`, `schemas/pass.schema.json`, mutant: "a pull request that moved once is the alert", mutant: "an issue that moved earns the grace", mutant: "what the last record left out for any reason counts as a move", mutant: "a nameless move in the last record is read as an item", mutant: "a move in the last pass earns the grace again", mutant: "a last record that cannot say grants the grace", mutant: "any read left out earns the grace", mutant: "the record keeps an item that names none", mutant: "an item listed twice is left out by its read's code", mutant: "a read's code of any word is kept", mutant: "any dict is an item a record keeps" |
| The entry keeps a moved snapshot as `pull_moved` and any other as `facts_unanswered`. A first move's writes are owed and not read, so reconciliation is whole. A second move's, or one after a record that cannot say, are read short | `tests/test_controller.py::test_a_snapshot_that_moved_is_kept_by_its_code_and_its_writes_are_owed_once`, `controller/__main__.py`, mutant: "the entry keeps no code of why a snapshot failed", mutant: "a pull request that moved once is read short", mutant: "every pull request left out is owed" |
| A read whose `updated_at` cannot be read, the first or the last, is a read GitHub did not answer, never a move | `tests/test_adapters.py::test_a_pull_request_whose_updated_at_cannot_be_read_has_not_moved`, `adapters/pull.py`, mutant: "a read with no time is a move" |
Loading
Loading