905 lines
46 KiB
Python
Executable File
905 lines
46 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Deterministic snapshot + state helper for ce-babysit-pr.
|
|
|
|
The agent (SKILL.md) owns judgment and mutations; this script owns the parts
|
|
prose cannot do reliably: a combined fetch of both event streams, atomic state
|
|
read/write under a file lock, and dedup keyed on remote truth.
|
|
|
|
The dedup model is claim -> act -> confirm. `snapshot` never marks an item
|
|
handled just because it observed it; an item stays actionable until either the
|
|
agent confirms it acted (`mark`) OR remote truth removes it (a resolved thread
|
|
drops out of the unresolved fetch). So if a resolve/debug pass crashes or fails
|
|
before the agent marks it, the item is still actionable on the next tick.
|
|
|
|
Subcommands:
|
|
snapshot --pr N [--repo O/R] --state-dir DIR [--fetch-file F]
|
|
Fetch (or load F), diff against on-disk state, persist the
|
|
observed state atomically, emit the actionable set as JSON.
|
|
mark --state-dir DIR (--thread ID --disposition needs-human|dispatched
|
|
| --comment ID --disposition needs-human|dispatched | --check KEY)
|
|
Record that the agent acted on an item. A `dispatched` thread is
|
|
re-emitted when a later reviewer comment moves its last-comment
|
|
identity past the one we acted on (our own reply does not
|
|
re-trigger); a `needs-human` thread stays parked until an explicit
|
|
`--disposition open`. A non-thread feedback item never drops out of
|
|
the fetch on its own, so `--comment` is the only way to silence a
|
|
handled one. A new head SHA clears dispatched CI checks.
|
|
|
|
--fetch-file injects a pre-captured combined snapshot instead of calling gh,
|
|
so the diff logic is testable without a live PR.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timezone
|
|
|
|
# Conclusions that mean "this check needs attention" (failing states).
|
|
FAILING = {"FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"}
|
|
|
|
DISPOSITION_OPEN = "open"
|
|
DISPOSITION_NEEDS_HUMAN = "needs-human"
|
|
DISPOSITION_DISPATCHED = "dispatched"
|
|
|
|
# A present in-progress review signal (👀) blocks a merge-ready read — but never terminally: after
|
|
# this much quiet a stale/never-cleared signal is ignored so the PR can still be called ready.
|
|
REVIEW_INPROGRESS_MAX_WAIT = 900
|
|
|
|
# Pure CI/status bots whose top-level comments are never review feedback (coverage deltas,
|
|
# deploy previews). Code-review bots (Codex, CodeRabbit, Greptile, etc.) are deliberately NOT
|
|
# here — their comments ARE feedback and must reach ce-resolve-pr-feedback. Matched case-folded
|
|
# against both the bare login and its "[bot]" suffix form.
|
|
CI_BOT_LOGINS = {
|
|
"github-actions", "codecov", "codecov-commenter", "netlify", "vercel",
|
|
}
|
|
|
|
# Trajectory check-history states (persisted, compared by ==).
|
|
CHECK_UNKNOWN = "unknown"
|
|
CHECK_CLEAR = "clear"
|
|
CHECK_FAILING = "failing"
|
|
# Trajectory single-stream activity labels.
|
|
STREAM_CI = "ci"
|
|
STREAM_REVIEW = "review"
|
|
# Keep a check's recurrence memory across a transient absence (a one-tick gap from a
|
|
# workflow-registration lag or a paths-filtered run), but bound growth: evict entries
|
|
# unseen for this many ticks.
|
|
CHECK_HISTORY_TTL = 30
|
|
|
|
|
|
def _now():
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _iso(dt):
|
|
return dt.isoformat()
|
|
|
|
|
|
def _run(cmd):
|
|
return subprocess.run(cmd, capture_output=True, text=True)
|
|
|
|
|
|
def _run_checked(cmd, label):
|
|
r = _run(cmd)
|
|
if r.returncode != 0:
|
|
raise SystemExit(f"{label} failed: {r.stderr.strip()}")
|
|
return r
|
|
|
|
|
|
def _split_repo(repo):
|
|
"""Parse "[HOST/]OWNER/NAME" into (owner, name), or (None, None). A host-qualified ref (gh's
|
|
documented `[HOST/]OWNER/REPO` selector) drops the host — the last two segments are what the
|
|
GraphQL lookup needs; treating the host as the owner would query a nonexistent repo on GHE."""
|
|
if not repo:
|
|
return None, None
|
|
parts = repo.strip("/").split("/")
|
|
if len(parts) >= 2:
|
|
return parts[-2], parts[-1]
|
|
return None, None
|
|
|
|
|
|
def _resolve_repo_ref(repo, url):
|
|
"""Resolve (owner, name, host) from --repo + the PR url, else one `gh repo view` call.
|
|
The host is parsed from the url and threaded into every `gh api` call so a GitHub Enterprise
|
|
PR queries the right host — without it, `gh api` defaults to github.com and a GHE babysitter
|
|
fetches the PR via `gh pr view` but then reads review threads / workflow runs from github.com.
|
|
Parsing the url (already fetched by `fetch`) also avoids a redundant `gh repo view` per tick."""
|
|
owner, name = _split_repo(repo)
|
|
host = None
|
|
if url:
|
|
# https://<host>/OWNER/NAME/pull/N
|
|
parts = url.rstrip("/").split("/")
|
|
if len(parts) >= 5 and parts[0].startswith("http"):
|
|
host = parts[2]
|
|
if not owner:
|
|
owner, name = parts[-4], parts[-3]
|
|
if not owner:
|
|
r = _run(["gh", "repo", "view", "--json", "owner,name"])
|
|
if r.returncode == 0:
|
|
info = json.loads(r.stdout)
|
|
owner, name = info.get("owner", {}).get("login"), info.get("name")
|
|
if not owner or not name:
|
|
raise SystemExit("could not resolve owner/repo; pass --repo OWNER/REPO")
|
|
return owner, name, host
|
|
|
|
|
|
def _host_args(host):
|
|
"""`gh api --hostname` selector so GHE calls hit the PR's host, not the default github.com."""
|
|
return ["--hostname", host] if host else []
|
|
|
|
|
|
def _thread_identity(t):
|
|
"""The remote-truth identity of a thread's latest state."""
|
|
return (t.get("last_comment_id"), t.get("last_comment_at"))
|
|
|
|
|
|
def fetch(pr, repo):
|
|
"""Fetch both event streams via gh into one combined snapshot dict."""
|
|
repo_args = ["--repo", repo] if repo else []
|
|
view = _run_checked(["gh", "pr", "view", str(pr), *repo_args, "--json",
|
|
"state,mergeable,mergeStateStatus,reviewDecision,headRefOid,url,number,"
|
|
"statusCheckRollup,author,comments,reviews,reactionGroups"],
|
|
"gh pr view")
|
|
v = json.loads(view.stdout)
|
|
|
|
checks = []
|
|
for c in v.get("statusCheckRollup") or []:
|
|
# CheckRun entries carry name/status/conclusion/workflowName/detailsUrl.
|
|
# StatusContext (legacy commit statuses) carry context/state/targetUrl.
|
|
if c.get("__typename") == "StatusContext":
|
|
name = c.get("context") or "status"
|
|
state = (c.get("state") or "").upper()
|
|
checks.append({
|
|
"key": name, "name": name,
|
|
"status": "COMPLETED" if state in ("SUCCESS", "FAILURE", "ERROR") else "IN_PROGRESS",
|
|
"conclusion": {"ERROR": "FAILURE"}.get(state, state) or None,
|
|
"details_url": c.get("targetUrl"),
|
|
})
|
|
else:
|
|
wf = c.get("workflowName")
|
|
name = c.get("name") or "check"
|
|
checks.append({
|
|
"key": f"{wf}/{name}" if wf else name, "name": name,
|
|
"status": (c.get("status") or "").upper() or "IN_PROGRESS",
|
|
"conclusion": (c.get("conclusion") or None) and c["conclusion"].upper(),
|
|
"details_url": c.get("detailsUrl"),
|
|
})
|
|
|
|
# In-progress review signal: an 👀 (EYES) reaction on the PR body is how several review bots
|
|
# (Codex among them) announce a review is *underway* — a present in-progress signal means the PR
|
|
# is NOT settled, regardless of quiet time. A present signal is meaningful; absence tells us
|
|
# nothing (many reviewers give no signal), so this only ever *delays* a merge-ready read.
|
|
review_in_progress = any(
|
|
g.get("content") == "EYES" and (g.get("users") or {}).get("totalCount", 0) > 0
|
|
for g in v.get("reactionGroups") or [])
|
|
|
|
owner, name, host = _resolve_repo_ref(repo, v.get("url"))
|
|
head = v.get("headRefOid")
|
|
return {
|
|
"pr_state": v.get("state"),
|
|
"mergeable": v.get("mergeable"),
|
|
"merge_state_status": v.get("mergeStateStatus"),
|
|
"review_decision": v.get("reviewDecision") or None,
|
|
"head_sha": head,
|
|
"url": v.get("url"),
|
|
"checks": checks,
|
|
"review_in_progress": review_in_progress,
|
|
"threads": fetch_threads(pr, owner, name, host),
|
|
# Non-thread feedback: top-level PR comments + review submission bodies. ce-resolve-pr-feedback
|
|
# handles these too, so a Changes-Requested review body or an actionable top-level comment with
|
|
# NO inline thread must not be invisible to the loop. Content-actionability is the resolver's
|
|
# judgment; here we just surface non-empty, non-author, non-CI-bot items so the loop invokes it.
|
|
"feedback": _extract_feedback(v),
|
|
"awaiting_approval": fetch_awaiting_approval(owner, name, head, host),
|
|
}
|
|
|
|
|
|
def _is_ci_bot(login):
|
|
"""A pure CI/status bot login, tolerant of the "[bot]" suffix gh reports."""
|
|
return login.lower().removesuffix("[bot]") in CI_BOT_LOGINS
|
|
|
|
|
|
def _body_hash(body):
|
|
"""Edit identity for a top-level comment / review body: `gh pr view --json` exposes no
|
|
updatedAt, so hash the body. When a dispatched wrapper is later edited to add an actionable
|
|
request, this changes and the item reactivates (unlike a thread, our reply is a separate
|
|
top-level comment and never edits the original, so there is no self-reply retrigger)."""
|
|
return hashlib.sha1((body or "").encode("utf-8")).hexdigest()[:16]
|
|
|
|
|
|
def _extract_feedback(v):
|
|
"""Top-level PR comments + non-empty review bodies, excluding the PR author and CI/status
|
|
bots. `gh pr view --json comments,reviews` returns flat arrays (not GraphQL {nodes})."""
|
|
author = (v.get("author") or {}).get("login")
|
|
out = []
|
|
for c in v.get("comments") or []:
|
|
a = (c.get("author") or {}).get("login")
|
|
if a and a != author and not _is_ci_bot(a) and (c.get("body") or "").strip():
|
|
out.append({"id": c.get("id"), "kind": "comment", "author": a, "edit_id": _body_hash(c.get("body"))})
|
|
for r in v.get("reviews") or []:
|
|
a = (r.get("author") or {}).get("login")
|
|
if a and a != author and not _is_ci_bot(a) and (r.get("body") or "").strip():
|
|
out.append({"id": r.get("id"), "kind": "review", "author": a, "state": r.get("state"),
|
|
"edit_id": _body_hash(r.get("body"))})
|
|
return out
|
|
|
|
|
|
def fetch_awaiting_approval(owner, name, head, host=None):
|
|
"""Count Actions workflow runs on this head that are awaiting maintainer approval —
|
|
the fork-PR security gate. Such a run has created NO check-run yet, so it is invisible
|
|
to statusCheckRollup; without this, a fork PR blocked on approval reads as 'all checks ok'.
|
|
Best-effort: any API/permission failure returns 0 rather than failing the tick."""
|
|
if not head:
|
|
return 0
|
|
r = _run(["gh", "api", *_host_args(host),
|
|
f"repos/{owner}/{name}/actions/runs?head_sha={head}&per_page=50",
|
|
"--jq", '[.workflow_runs[] | select(.status==\"action_required\" or .status==\"waiting\" '
|
|
'or .conclusion==\"action_required\")] | length'])
|
|
if r.returncode != 0:
|
|
return 0
|
|
try:
|
|
return int((r.stdout or "").strip() or 0)
|
|
except ValueError:
|
|
return 0
|
|
|
|
|
|
def fetch_threads(pr, owner, name, host=None):
|
|
"""Unresolved review threads with their last-comment identity."""
|
|
query = """
|
|
query($owner:String!,$repo:String!,$pr:Int!,$cursor:String){
|
|
repository(owner:$owner,name:$repo){ pullRequest(number:$pr){
|
|
reviewThreads(first:100,after:$cursor){
|
|
nodes{ id isResolved path line
|
|
comments(last:100){ nodes{ id createdAt lastEditedAt } } }
|
|
pageInfo{ hasNextPage endCursor } } } } }"""
|
|
out = []
|
|
cursor = None
|
|
while True:
|
|
args = ["gh", "api", "graphql", *_host_args(host), "-f", f"owner={owner}", "-f", f"repo={name}",
|
|
"-F", f"pr={pr}", "-f", f"query={query}"]
|
|
if cursor:
|
|
args += ["-f", f"cursor={cursor}"]
|
|
r = _run_checked(args, "gh api graphql")
|
|
data = json.loads(r.stdout)["data"]["repository"]["pullRequest"]["reviewThreads"]
|
|
for n in data["nodes"]:
|
|
if n.get("isResolved"):
|
|
continue
|
|
cs = n.get("comments", {}).get("nodes") or []
|
|
last = cs[-1] if cs else {}
|
|
# `last_comment_at` is the reactivation signal — the MAX edit/create time across every
|
|
# comment in the thread, not just the last one, so a reviewer editing an *earlier* comment
|
|
# (their original request) after the agent's reply still moves the identity and re-opens it.
|
|
# Bounded to the last 100 comments (see the query): review threads are ~never that long; an
|
|
# edit to a comment outside that window would be missed (acceptable vs paginating per thread).
|
|
edit_at = max((c.get("lastEditedAt") or c.get("createdAt") or "" for c in cs), default="")
|
|
out.append({
|
|
"thread_id": n["id"],
|
|
"last_comment_id": last.get("id"),
|
|
"last_comment_at": edit_at or last.get("lastEditedAt") or last.get("createdAt"),
|
|
"path": n.get("path"),
|
|
"line": n.get("line"),
|
|
})
|
|
if not data["pageInfo"]["hasNextPage"]:
|
|
break
|
|
cursor = data["pageInfo"]["endCursor"]
|
|
return out
|
|
|
|
|
|
def _empty_state(pr, repo, url, now):
|
|
owner, name = _split_repo(repo)
|
|
return {
|
|
"pr": {"owner": owner, "repo": name, "number": pr, "url": url},
|
|
"head_sha": None, "tick": 0, "started_at": _iso(now),
|
|
"checks": {}, "threads": {}, "ci_dispatched": {},
|
|
"review_decision": None, "mergeable": None, "merge_state_status": None,
|
|
"last_change_at": None, "last_action": None, "stop_reason": None,
|
|
"trajectory": _empty_trajectory(),
|
|
}
|
|
|
|
|
|
def _empty_trajectory():
|
|
"""Deterministic cross-tick facts babysit hands the leaves (facts, not judgment):
|
|
babysit ships the trajectory, the leaf decides whether it means non-convergence."""
|
|
return {
|
|
"check_history": {}, # check key -> {state, last_head, recur, seen_tick}
|
|
"seen_threads": {}, # unresolved thread id -> first-seen tick
|
|
"unresolved_series": [], # unresolved-thread count per tick (last 6)
|
|
"stream_series": [], # single-stream activity per tick (last 8)
|
|
"problem_keys": [], # last tick's failing checks + non-parked threads (progress detection)
|
|
"min_open_problems": None, # lowest total open-problem count seen
|
|
"heads_since_progress": 0, # head changes since progress (a new low OR something cleared)
|
|
"last_head": None, # head as of the last AGENT tick — hsp counts moves between ticks,
|
|
# NOT poll-observed head moves (state["head_sha"] advances on polls)
|
|
}
|
|
|
|
|
|
def _load_trajectory(state):
|
|
"""Load the trajectory, tolerating a partial or non-dict value from an older on-disk
|
|
state.json (persisted in /tmp across script versions) — backfill missing keys so a new
|
|
field never KeyErrors an old state, and a null/garbage value never crashes."""
|
|
tj = state.get("trajectory")
|
|
if not isinstance(tj, dict):
|
|
tj = {}
|
|
for key, value in _empty_trajectory().items():
|
|
tj.setdefault(key, value)
|
|
state["trajectory"] = tj
|
|
return tj
|
|
|
|
|
|
def _push_bounded(lst, item, cap):
|
|
"""Append to a sliding window that keeps only the last `cap` items."""
|
|
lst.append(item)
|
|
del lst[:-cap]
|
|
|
|
|
|
def _stream_alternations(series):
|
|
"""Count flips between ci-active and review-active ticks — the cross-stream churn signal."""
|
|
flips = 0
|
|
prev = None
|
|
for s in series:
|
|
if prev is not None and s != prev:
|
|
flips += 1
|
|
prev = s
|
|
return flips
|
|
|
|
|
|
def _trend(series):
|
|
if len(series) < 3:
|
|
return "flat"
|
|
if series[-1] > series[0]:
|
|
return "rising"
|
|
if series[-1] < series[0]:
|
|
return "falling"
|
|
return "flat"
|
|
|
|
|
|
def _record_check_history(state, head, new_checks):
|
|
"""Record CI fail->clear->fail recurrence transitions. Runs on EVERY snapshot — agent ticks AND
|
|
watch polls — so a CLEAR (or FAIL) observed only between agent ticks is not lost, which would
|
|
otherwise make the ping-pong recurrence trigger under-fire under the self-sustaining watch.
|
|
Idempotent per transition: once a check is FAILING, re-observing FAIL does not re-increment."""
|
|
tj = _load_trajectory(state)
|
|
tick = state.get("tick", 0)
|
|
hist = tj["check_history"]
|
|
for key, c in new_checks.items():
|
|
h = hist.setdefault(key, {"state": CHECK_UNKNOWN, "last_head": None, "recur": 0, "seen_tick": tick})
|
|
h["seen_tick"] = tick
|
|
if c["conclusion"] in FAILING:
|
|
if h["state"] == CHECK_CLEAR and h["last_head"] != head: # fail after a clear on a new head
|
|
h["recur"] += 1
|
|
h["state"] = CHECK_FAILING
|
|
h["last_head"] = head
|
|
elif c["status"] == "COMPLETED": # observed non-failing terminal — a genuine clear
|
|
h["state"] = CHECK_CLEAR
|
|
h["last_head"] = head
|
|
# IN_PROGRESS/QUEUED: leave prior state untouched (not yet a clear)
|
|
# Evict entries unseen for TTL agent-ticks (polls don't advance the tick) — bounds growth without
|
|
# erasing a check that was briefly absent (a one-tick gap must not lose real recurrence history).
|
|
tj["check_history"] = {k: v for k, v in hist.items() if tick - v.get("seen_tick", tick) <= CHECK_HISTORY_TTL}
|
|
|
|
|
|
def _update_trajectory(state, head, new_checks, new_threads, new_feedback, actionable):
|
|
"""Maintain and emit the deterministic trajectory. Coarse by design: check-name-level
|
|
recurrence, backlog trend, cross-stream alternation, no-progress heads. Fine, invariant-
|
|
level judgment (log signatures, nit root-clustering) is the leaf's job, not this script's.
|
|
`actionable` is the `{ci, threads, comments}` set diff() also returns. Non-thread feedback
|
|
(top-level comments + review bodies) counts as review-stream activity and as an open problem
|
|
for the stall signal, but the thread-named backlog fields stay scoped to inline threads."""
|
|
tj = _load_trajectory(state)
|
|
|
|
# --- CI recurrence (fail->clear->fail on a *different* head): recorded on EVERY observation
|
|
# (agent ticks AND watch polls, via _record_check_history in diff) so a clear seen only between
|
|
# agent ticks is not lost. Here we just READ the accumulated history for the trigger. recur_max
|
|
# reflects only checks present this tick, so a stale key can't keep it elevated. ---
|
|
hist = tj["check_history"]
|
|
recur_max = max((hist[k]["recur"] for k in new_checks if k in hist), default=0)
|
|
recurring = [{"key": k, "recur": hist[k]["recur"]} for k in new_checks if k in hist and hist[k]["recur"] > 0]
|
|
|
|
# --- Review-thread backlog: trend of unresolved-thread count + genuinely new threads this
|
|
# tick. Scoped to inline threads (non-thread feedback is captured in the total-problem stall
|
|
# signal below), so these thread-named fields stay accurate. ---
|
|
seen = tj.get("seen_threads", {})
|
|
new_arrivals = [tid for tid in new_threads if tid not in seen]
|
|
tj["seen_threads"] = {tid: seen.get(tid, state.get("tick", 0)) for tid in new_threads}
|
|
_push_bounded(tj["unresolved_series"], len(new_threads), 6)
|
|
|
|
# --- Cross-stream churn: alternation between ci-only and review-only active ticks.
|
|
# Review is active when either threads OR non-thread feedback is actionable. ---
|
|
review_active = bool(actionable["threads"] or actionable.get("comments"))
|
|
if actionable["ci"] and not review_active:
|
|
active = STREAM_CI
|
|
elif review_active and not actionable["ci"]:
|
|
active = STREAM_REVIEW
|
|
else:
|
|
active = None # both or neither — not a single-stream tick, don't record
|
|
if active:
|
|
_push_bounded(tj["stream_series"], active, 8)
|
|
|
|
# --- No-progress heads: measured from TOTAL open problems (failing checks + non-parked
|
|
# unresolved threads + non-parked non-thread feedback), NOT the post-claim `actionable` set —
|
|
# marking items dispatched shrinks
|
|
# actionable and would fake progress. Reset the counter when the head moves and either the
|
|
# total set a new low OR a previously-failing item cleared: progressive migration (A cleared
|
|
# while B appears) is progress, not a stall, so it must not accrue heads_since_progress. ---
|
|
# Only genuinely-OPEN items are unresolved work: a `dispatched` item is handled (a top-level
|
|
# comment never drops out of the fetch, so counting it would keep heads_since_progress climbing
|
|
# forever and falsely trip non-convergence on unrelated later work), and `needs-human` is parked.
|
|
problem_keys = {f"c:{k}" for k, c in new_checks.items() if c["conclusion"] in FAILING}
|
|
problem_keys |= {f"t:{tid}" for tid, t in new_threads.items() if t.get("disposition") == DISPOSITION_OPEN}
|
|
problem_keys |= {f"m:{fid}" for fid, f in new_feedback.items() if f.get("disposition") == DISPOSITION_OPEN}
|
|
cleared_something = bool(set(tj.get("problem_keys", [])) - problem_keys)
|
|
open_problems = len(problem_keys)
|
|
minp = tj.get("min_open_problems")
|
|
new_low = minp is None or open_problems < minp
|
|
if new_low:
|
|
tj["min_open_problems"] = open_problems
|
|
# heads_since_progress counts head moves BETWEEN AGENT TICKS (tj["last_head"]), not poll-observed
|
|
# moves — a watch poll advances state["head_sha"], so a plain head_changed would read False at the
|
|
# agent's tick and starve this stall signal under the default self-sustaining watch.
|
|
traj_head_moved = tj.get("last_head") is not None and head != tj.get("last_head")
|
|
if new_low or cleared_something:
|
|
tj["heads_since_progress"] = 0
|
|
elif traj_head_moved:
|
|
tj["heads_since_progress"] = tj.get("heads_since_progress", 0) + 1
|
|
tj["problem_keys"] = sorted(problem_keys)
|
|
tj["last_head"] = head
|
|
|
|
return {
|
|
"recurring_checks": recurring,
|
|
"check_recur_max": recur_max,
|
|
"unresolved_threads": len(new_threads),
|
|
"unresolved_series": list(tj["unresolved_series"]),
|
|
"unresolved_trend": _trend(tj["unresolved_series"]),
|
|
"new_threads_this_tick": len(new_arrivals),
|
|
"stream_alternations": _stream_alternations(tj["stream_series"]),
|
|
"heads_since_progress": tj["heads_since_progress"],
|
|
}
|
|
|
|
|
|
def _apply_dispositions(items, id_key, prior, identity_fn=None):
|
|
"""Claim->act->confirm dedup for a review stream: an item stays actionable until `mark`
|
|
records a non-open disposition (persisted in prior[id]['disposition']). Returns
|
|
(persisted_by_id, actionable_list, open_needs_human_count).
|
|
|
|
When identity_fn is given, a `dispatched` **or** `needs-human` item is *reactivated* — set back
|
|
to open and re-actionized — once its last-comment / edit identity moves past the one we acted on.
|
|
That acted-on identity is captured lazily on the first tick we observe the item parked, which is
|
|
*after* our own reply (a fix acknowledgment, or a needs-human `decision_context` reply) has
|
|
already landed in the fetch — so our reply becomes the baseline and does not re-trigger, while a
|
|
genuine later comment does. For `needs-human` this is exactly how a **human answering the parked
|
|
question** (a reviewer reply, or an edit of the top-level comment) re-opens it and wakes the loop,
|
|
instead of it sitting parked forever; an explicit `mark --disposition open` still forces it too.
|
|
This is also what stops a dispatched-but-unresolved thread with fresh reviewer activity from being
|
|
silenced out of counts.threads and letting the merge-ready gate call the PR ready. An item with no
|
|
identity_fn never auto-reactivates (a brand-new top-level comment is just a new id)."""
|
|
persisted, actionable, needs_human = {}, [], 0
|
|
for it in items:
|
|
iid = it.get(id_key)
|
|
if not iid:
|
|
continue
|
|
pri = prior.get(iid, {})
|
|
disposition = pri.get("disposition", DISPOSITION_OPEN)
|
|
acted_identity = pri.get("acted_identity")
|
|
if disposition in (DISPOSITION_DISPATCHED, DISPOSITION_NEEDS_HUMAN) and identity_fn is not None:
|
|
current_identity = identity_fn(it)
|
|
if acted_identity is None:
|
|
acted_identity = current_identity # first post-action observation: adopt as baseline
|
|
elif current_identity != acted_identity:
|
|
disposition = DISPOSITION_OPEN # a later human/reviewer reply past our baseline -> reactivate
|
|
acted_identity = None
|
|
if disposition == DISPOSITION_OPEN:
|
|
actionable.append(it)
|
|
elif disposition == DISPOSITION_NEEDS_HUMAN:
|
|
needs_human += 1
|
|
rec = {**it, "disposition": disposition}
|
|
if acted_identity is not None:
|
|
rec["acted_identity"] = acted_identity
|
|
persisted[iid] = rec
|
|
return persisted, actionable, needs_human
|
|
|
|
|
|
def diff(state, cur, now=None, advance_trajectory=True):
|
|
"""Pure: given prior state + current snapshot, compute the actionable set
|
|
and the persisted observed state. `now` is injectable for tests."""
|
|
now = now or _now()
|
|
# A transient null/empty head (a gh hiccup) falls back to the last known head,
|
|
# so a momentary null does not look like a new head and wipe ci_dispatched.
|
|
head = cur["head_sha"] or state.get("head_sha")
|
|
head_changed = state.get("head_sha") is not None and head != state["head_sha"]
|
|
|
|
prior_threads = state.get("threads", {})
|
|
prior_feedback = state.get("feedback", {})
|
|
prior_change_sig = _change_sig(state)
|
|
|
|
if head_changed:
|
|
# SHA-scoped state is meaningless on a new head.
|
|
state["ci_dispatched"] = {}
|
|
|
|
# --- CI: a failing check on the current head is actionable until the agent
|
|
# marks it dispatched (recorded in ci_dispatched[head]) or the head moves.
|
|
# `checks_terminal` = every check has finished (none IN_PROGRESS/QUEUED).
|
|
# Duplicate check keys (same workflow/name) are disambiguated with a #n suffix
|
|
# so one never shadows another and silently drops a failing check. ---
|
|
dispatched = set(state.get("ci_dispatched", {}).get(head, []))
|
|
new_checks = {}
|
|
actionable_ci = []
|
|
has_failing = False
|
|
checks_terminal = True
|
|
seen_keys = {}
|
|
for c in cur["checks"]:
|
|
key = c["key"]
|
|
if key in seen_keys:
|
|
seen_keys[key] += 1
|
|
key = f"{key}#{seen_keys[key]}"
|
|
else:
|
|
seen_keys[key] = 0
|
|
new_checks[key] = {"name": c["name"], "status": c["status"],
|
|
"conclusion": c["conclusion"], "head_sha": head}
|
|
if c["status"] != "COMPLETED":
|
|
checks_terminal = False
|
|
if c["conclusion"] in FAILING:
|
|
has_failing = True
|
|
if key not in dispatched:
|
|
actionable_ci.append({"key": key, "name": c["name"],
|
|
"conclusion": c["conclusion"], "details_url": c["details_url"]})
|
|
|
|
# Both review streams share claim->act->confirm dedup (see _apply_dispositions), differing
|
|
# in how an item leaves the fetch and whether new activity re-opens it:
|
|
# - Threads: a resolved thread drops out of the unresolved fetch. A `dispatched` thread that
|
|
# is still unresolved is reactivated once its last-comment identity moves past the one we
|
|
# acted on (acted_identity) — so a reviewer re-engaging is not silently dropped, yet the
|
|
# acting loop's own reply (captured as the baseline on first observation) does not
|
|
# re-trigger. A `needs-human` thread stays parked (open_needs_human keeps merge-ready from
|
|
# firing) until a *human* answers it — a later reply/edit past that same baseline reopens and
|
|
# wakes it — or an explicit `mark --disposition open` forces it.
|
|
# - Feedback (top-level comments + review bodies): no remote "resolve" exists, so an item
|
|
# never drops out on its own — `mark --comment <id>` is the only thing that silences it. A
|
|
# dispatched item reactivates only when its own body is *edited* (edit_id changes) to add a
|
|
# new request; a brand-new comment is just a new id. Our reply is a separate top-level
|
|
# comment, never an edit of the original, so it never retriggers.
|
|
# Either stream's open needs-human items feed open_needs_human so the merge-ready gate
|
|
# refuses to call the PR ready while a human decision is still pending.
|
|
new_threads, actionable_threads, human_threads = _apply_dispositions(
|
|
cur["threads"], "thread_id", prior_threads,
|
|
identity_fn=lambda t: [t.get("last_comment_id"), t.get("last_comment_at")])
|
|
new_feedback, actionable_feedback, human_feedback = _apply_dispositions(
|
|
cur.get("feedback") or [], "id", prior_feedback, identity_fn=lambda c: [c.get("edit_id")])
|
|
open_needs_human = human_threads + human_feedback
|
|
# Identities of the currently-parked needs-human items, so the watch can tell an already-
|
|
# surfaced residual (do not re-wake) from a newly-arrived one (wake) — a parked human decision
|
|
# must not busy-wake the loop or terminate it.
|
|
needs_human_ids = sorted(
|
|
[k for k, r in new_threads.items() if r.get("disposition") == DISPOSITION_NEEDS_HUMAN]
|
|
+ [k for k, r in new_feedback.items() if r.get("disposition") == DISPOSITION_NEEDS_HUMAN])
|
|
|
|
state["head_sha"] = head or state.get("head_sha")
|
|
state["checks"] = new_checks
|
|
state["threads"] = new_threads
|
|
state["feedback"] = new_feedback
|
|
state["review_decision"] = cur["review_decision"]
|
|
state["mergeable"] = cur["mergeable"]
|
|
state["merge_state_status"] = cur["merge_state_status"]
|
|
state["review_in_progress"] = cur.get("review_in_progress", False)
|
|
state["awaiting_approval"] = cur.get("awaiting_approval", 0)
|
|
|
|
actionable = {"ci": actionable_ci, "threads": actionable_threads, "comments": actionable_feedback}
|
|
if advance_trajectory:
|
|
state["tick"] = state.get("tick", 0) + 1
|
|
# Record CI recurrence transitions on EVERY observation (polls too) so a fail->clear->fail seen
|
|
# only between agent ticks is not lost. head_sha for the check-level last_head is the observed
|
|
# head; the trajectory-level last_head (for heads_since_progress) is agent-tick-only, inside
|
|
# _update_trajectory.
|
|
_record_check_history(state, head, new_checks)
|
|
if advance_trajectory:
|
|
trajectory = _update_trajectory(state, head, new_checks, new_threads, new_feedback, actionable)
|
|
else:
|
|
# A watch poll detects change and advances the settle clock, but must NOT roll the rest of the
|
|
# trajectory (tick counter, seen_threads, unresolved_series, heads_since_progress). Advancing
|
|
# it would consume new_threads_this_tick — the waking poll marks the just-arrived thread
|
|
# "seen", so the agent's real tick reads 0 new arrivals and the review-bot-treadmill
|
|
# non-convergence trigger never fires. Only the agent's tick (advance_trajectory=True) rolls it.
|
|
trajectory = {}
|
|
|
|
# --- Settle window: any observable movement resets the quiet clock. ---
|
|
changed_this_tick = head_changed or _change_sig(state) != prior_change_sig or state.get("last_change_at") is None
|
|
if changed_this_tick:
|
|
state["last_change_at"] = _iso(now)
|
|
quiet_seconds = _elapsed(state.get("last_change_at"), now)
|
|
|
|
# Workflow runs awaiting maintainer approval (fork-PR gate) create no check-run, so they are
|
|
# invisible to the rollup above — surface them, and never call CI "ok" while the real CI is
|
|
# gated. blocked_external = the loop cannot progress (no failing check to fix, but CI can't run)
|
|
# and no one in this loop can unblock it — it is up to a maintainer of the base repo.
|
|
awaiting_approval = state["awaiting_approval"]
|
|
# "OK" requires every check terminal, none failing, AND none gated on approval. A still-
|
|
# IN_PROGRESS or awaiting-approval check is neither ok nor failing — do not exit green.
|
|
all_checks_ok = checks_terminal and not has_failing and bool(cur["checks"]) and awaiting_approval == 0
|
|
blocked_external = (awaiting_approval > 0 and not has_failing and checks_terminal
|
|
and not actionable_threads and not actionable_feedback)
|
|
|
|
return {
|
|
"pr_state": cur["pr_state"],
|
|
"mergeable": cur["mergeable"],
|
|
"merge_state_status": cur["merge_state_status"],
|
|
"review_decision": cur["review_decision"],
|
|
"head_sha": head,
|
|
"head_changed": head_changed,
|
|
"url": cur["url"],
|
|
"has_failing_checks": has_failing,
|
|
"checks_terminal": checks_terminal,
|
|
"checks_present": bool(cur["checks"]),
|
|
"all_checks_ok": all_checks_ok,
|
|
"review_in_progress": cur.get("review_in_progress", False),
|
|
"checks_awaiting_approval": awaiting_approval,
|
|
"blocked_external": blocked_external,
|
|
"open_needs_human": open_needs_human,
|
|
"needs_human_ids": needs_human_ids,
|
|
"actionable": {"threads": actionable_threads, "ci": actionable_ci, "comments": actionable_feedback},
|
|
"counts": {"threads": len(actionable_threads), "ci": len(actionable_ci),
|
|
"comments": len(actionable_feedback), "needs_human": open_needs_human},
|
|
"changed_this_tick": changed_this_tick,
|
|
"quiet_seconds": quiet_seconds,
|
|
"session_seconds": _elapsed(state.get("started_at"), now),
|
|
"tick": state["tick"],
|
|
"trajectory": trajectory,
|
|
}, state
|
|
|
|
|
|
def _change_sig(state):
|
|
"""Everything whose movement should reset the settle clock."""
|
|
checks = {k: (v.get("status"), v.get("conclusion")) for k, v in state.get("checks", {}).items()}
|
|
threads = {tid: _thread_identity(v) for tid, v in state.get("threads", {}).items()}
|
|
feedback = {fid: v.get("disposition") for fid, v in state.get("feedback", {}).items()}
|
|
return (checks, threads, feedback, state.get("review_decision"), state.get("mergeable"),
|
|
state.get("merge_state_status"), state.get("review_in_progress"),
|
|
# awaiting-approval clearing is movement: a fork gate lifting must reset the settle clock
|
|
# so merge-ready waits for the now-imminent check-runs instead of firing on an empty rollup.
|
|
bool(state.get("awaiting_approval")))
|
|
|
|
|
|
def _elapsed(iso_str, now):
|
|
try:
|
|
return int((now - datetime.fromisoformat(iso_str)).total_seconds())
|
|
except (ValueError, TypeError):
|
|
return 0
|
|
|
|
|
|
@contextmanager
|
|
def locked_state(state_dir, pr, repo, now):
|
|
import fcntl
|
|
os.makedirs(state_dir, exist_ok=True)
|
|
lock_path = os.path.join(state_dir, "lock")
|
|
state_path = os.path.join(state_dir, "state.json")
|
|
with open(lock_path, "w") as lf:
|
|
fcntl.flock(lf, fcntl.LOCK_EX)
|
|
if os.path.exists(state_path):
|
|
with open(state_path) as f:
|
|
state = json.load(f)
|
|
else:
|
|
state = _empty_state(pr, repo, None, now)
|
|
box = {"state": state}
|
|
yield box
|
|
tmp = tempfile.NamedTemporaryFile("w", dir=state_dir, delete=False)
|
|
json.dump(box["state"], tmp, indent=2)
|
|
tmp.flush()
|
|
os.fsync(tmp.fileno())
|
|
tmp.close()
|
|
os.replace(tmp.name, state_path)
|
|
fcntl.flock(lf, fcntl.LOCK_UN)
|
|
|
|
|
|
def _run_snapshot(args, now, advance_trajectory=True):
|
|
"""One fetch -> diff -> persist. Returns the actionable/state dict."""
|
|
if args.fetch_file:
|
|
with open(args.fetch_file) as f:
|
|
cur = json.load(f)
|
|
else:
|
|
cur = fetch(args.pr, args.repo)
|
|
with locked_state(args.state_dir, args.pr, args.repo, now) as box:
|
|
if box["state"].get("pr", {}).get("url") is None and cur.get("url"):
|
|
box["state"]["pr"]["url"] = cur["url"]
|
|
if getattr(args, "reset_session", False):
|
|
# A new babysit session (fresh invocation / watch arm) starts the budget clock now. Without
|
|
# this, `started_at` persists from state creation, so resuming against day-old state would
|
|
# read session_seconds as huge and hand back as "budget exhausted" before doing any work.
|
|
box["state"]["started_at"] = _iso(now)
|
|
actionable, box["state"] = diff(box["state"], cur, now, advance_trajectory=advance_trajectory)
|
|
return actionable
|
|
|
|
|
|
def cmd_snapshot(args):
|
|
print(json.dumps(_run_snapshot(args, _now()), indent=2))
|
|
|
|
|
|
def _wake_reason(a, settle_seconds):
|
|
"""Why the in-session agent should wake and run a tick, or None to keep watching.
|
|
Ordered by precedence — a terminal/blocked/needs-human state outranks a merge-ready read."""
|
|
if a.get("pr_state") in ("MERGED", "CLOSED"):
|
|
return "terminal"
|
|
if a.get("blocked_external"):
|
|
return "blocked-external"
|
|
c = a.get("counts") or {}
|
|
if c.get("threads", 0) or c.get("comments", 0) or c.get("ci", 0):
|
|
return "actionable"
|
|
if a.get("open_needs_human", 0):
|
|
return "needs-human" # parked items block ready; surface them
|
|
if a.get("has_failing_checks") and a.get("checks_terminal"):
|
|
# a dispatched check left terminally red (counts.ci is 0 — nothing new to dispatch) is a
|
|
# blocker to hand back, not a reason to idle to max-runtime.
|
|
return "blocked-failing"
|
|
# merge-ready candidate: green + settled AND no review in flight. An 👀-style in-progress signal
|
|
# means a review is underway, so the PR is NOT ready no matter how long it has been quiet — time
|
|
# is one component of "settled", not the gate. Honor the signal only up to a generous bound so a
|
|
# stale reaction that never clears cannot block ready forever (absence never blocks; presence
|
|
# only delays). Only wake once quiet has actually elapsed, so a stable green PR is not roused
|
|
# every poll while it cools off.
|
|
review_blocking = a.get("review_in_progress") and a.get("quiet_seconds", 0) < REVIEW_INPROGRESS_MAX_WAIT
|
|
# Interactive merge-ready does NOT require `all_checks_ok`'s "at least one observed check": a repo
|
|
# with no configured checks has a CLEAN/MERGEABLE PR that should be callable ready. (That guard
|
|
# stays in pipeline success, where a not-yet-created rollup must not read as a pass.)
|
|
if (a.get("mergeable") == "MERGEABLE" and a.get("merge_state_status") == "CLEAN"
|
|
and a.get("checks_terminal") and not a.get("has_failing_checks")
|
|
and a.get("checks_awaiting_approval", 0) == 0
|
|
and not review_blocking
|
|
and a.get("quiet_seconds", 0) >= settle_seconds):
|
|
return "merge-ready"
|
|
return None
|
|
|
|
|
|
def _emit_wake(reason, **fields):
|
|
print(json.dumps({"event": "BABYSIT_WAKE", "reason": reason, **fields}), flush=True)
|
|
|
|
|
|
def _blocker_sig(a):
|
|
"""Identity of blockers the agent surfaces once but cannot self-clear — parked needs-human
|
|
items, a dispatched terminally-red check, and a fork workflow awaiting maintainer approval. The
|
|
watch captures this at arm time and does not re-wake on a blocker already in that baseline, so a
|
|
parked residual keeps the watch alive (or the blocked-external bounded watch keeps polling for
|
|
the gate to clear) instead of busy-waking or terminating on the same condition."""
|
|
sig = set(a.get("needs_human_ids") or [])
|
|
if a.get("has_failing_checks") and a.get("checks_terminal") and not (a.get("counts") or {}).get("ci"):
|
|
sig.add("__terminal_red__")
|
|
if a.get("blocked_external"):
|
|
sig.add("__blocked_external__")
|
|
# A new head is "context materially changed": a human may have pushed a commit that answers or
|
|
# supersedes a parked residual, so the head is part of the baseline — when it moves, the residual
|
|
# is no longer "already-surfaced against this state" and the watch wakes to give the agent a tick
|
|
# to reopen/reprocess it, instead of parking forever while it still blocks merge-ready.
|
|
if sig:
|
|
sig.add(("head", a.get("head_sha")))
|
|
return frozenset(sig)
|
|
|
|
|
|
def cmd_watch(args):
|
|
"""Deterministic background change-detector (no agent tokens between changes): poll on an
|
|
interval, print one wake sentinel line and exit when there is something for the agent to do
|
|
(an actionable change or a stop condition), or exit on the stop-signal file / max-runtime.
|
|
A residual already present at arm time (a parked needs-human, or a dispatched terminal-red the
|
|
agent already handed back) does NOT re-wake the loop — it keeps watching the other streams;
|
|
only a *new* blocker (signature grown past the baseline) wakes."""
|
|
deadline = (time.monotonic() + args.max_runtime) if args.max_runtime else None
|
|
armed = None # baseline blocker signature captured on the first poll (already-surfaced residuals)
|
|
args.reset_session = True # arming a watch is a fresh session -> start the budget clock now
|
|
while True:
|
|
if args.stop_file and os.path.exists(args.stop_file):
|
|
_emit_wake("stop-signal")
|
|
return
|
|
actionable = _run_snapshot(args, _now(), advance_trajectory=False) # a poll must not roll the trajectory
|
|
args.reset_session = False # ...but later polls are ticks of that same session, not new ones
|
|
if armed is None:
|
|
armed = _blocker_sig(actionable)
|
|
reason = _wake_reason(actionable, args.settle_seconds)
|
|
if reason in ("needs-human", "blocked-failing", "blocked-external") and _blocker_sig(actionable) <= armed:
|
|
reason = None # already-surfaced residual — keep watching, do not re-wake or terminate
|
|
if reason:
|
|
_emit_wake(reason, url=actionable.get("url"),
|
|
pr_state=actionable.get("pr_state"), counts=actionable.get("counts"))
|
|
return
|
|
if deadline is not None and time.monotonic() >= deadline:
|
|
_emit_wake("max-runtime", url=actionable.get("url"))
|
|
return
|
|
time.sleep(args.interval)
|
|
|
|
|
|
def _mark_thread_baseline(item_id, args, state):
|
|
"""Best-effort current last-comment identity of a thread, captured at mark time so our just-posted
|
|
reply becomes the acted baseline directly. Without this the baseline is adopted lazily on the next
|
|
snapshot, so a reviewer reply that races in between the reply and that snapshot would be adopted as
|
|
the baseline and silently swallowed instead of reactivating the thread. A fetch hiccup falls back
|
|
to the lazy baseline (acted_identity stays unset)."""
|
|
try:
|
|
if getattr(args, "fetch_file", None):
|
|
threads = json.load(open(args.fetch_file)).get("threads", [])
|
|
else:
|
|
owner, name, host = _resolve_repo_ref(args.repo, (state.get("pr") or {}).get("url"))
|
|
threads = fetch_threads(args.pr, owner, name, host)
|
|
for t in threads:
|
|
if t.get("thread_id") == item_id:
|
|
return [t.get("last_comment_id"), t.get("last_comment_at")]
|
|
except (SystemExit, Exception): # SystemExit (from _run_checked) is not an Exception subclass
|
|
pass
|
|
return None
|
|
|
|
|
|
def cmd_mark(args):
|
|
now = _now()
|
|
with locked_state(args.state_dir, args.pr, args.repo, now) as box:
|
|
state = box["state"]
|
|
if args.check:
|
|
head = state.get("head_sha")
|
|
if not head:
|
|
raise SystemExit("mark --check requires a prior snapshot (state has no head_sha)")
|
|
state.setdefault("ci_dispatched", {}).setdefault(head, [])
|
|
if args.check not in state["ci_dispatched"][head]:
|
|
state["ci_dispatched"][head].append(args.check)
|
|
state["last_action"] = f"dispatched check {args.check}"
|
|
elif args.thread or args.comment:
|
|
item_id = args.thread or args.comment
|
|
collection, id_field, label = (
|
|
("threads", "thread_id", "thread") if args.thread else ("feedback", "id", "comment"))
|
|
entry = state.setdefault(collection, {}).setdefault(item_id, {id_field: item_id})
|
|
entry["disposition"] = args.disposition
|
|
if args.disposition == DISPOSITION_OPEN:
|
|
entry.pop("acted_identity", None) # reopened -> next dispatch/park re-baselines
|
|
elif args.disposition in (DISPOSITION_DISPATCHED, DISPOSITION_NEEDS_HUMAN):
|
|
if args.thread:
|
|
# our reply moved the thread's last comment, so re-read it now as the baseline.
|
|
ident = _mark_thread_baseline(item_id, args, state)
|
|
if ident is not None:
|
|
entry["acted_identity"] = ident
|
|
elif args.comment and args.acted_edit_id:
|
|
# our reply is a separate top-level comment and never edits THIS one, so the
|
|
# snapshot-time edit_id the agent passes is already the correct baseline (no fetch).
|
|
entry["acted_identity"] = [args.acted_edit_id]
|
|
state["last_action"] = f"{args.disposition} {label} {item_id}"
|
|
print(json.dumps({"marked": args.check or args.thread or args.comment}))
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser(prog="pr-snapshot")
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
s = sub.add_parser("snapshot")
|
|
s.add_argument("--pr", type=int, required=True)
|
|
s.add_argument("--repo", default=None)
|
|
s.add_argument("--state-dir", required=True)
|
|
s.add_argument("--fetch-file", default=None)
|
|
s.add_argument("--reset-session", action="store_true") # start the budget clock (a fresh invocation)
|
|
s.set_defaults(func=cmd_snapshot)
|
|
|
|
m = sub.add_parser("mark")
|
|
m.add_argument("--pr", type=int, default=0)
|
|
m.add_argument("--repo", default=None)
|
|
m.add_argument("--state-dir", required=True)
|
|
m.add_argument("--thread", default=None)
|
|
# `open` re-actionizes a parked thread — the explicit re-open the SKILL prose relies on when a
|
|
# parked stream's context materially changes (a human pushed, the check universe moved).
|
|
m.add_argument("--disposition", choices=[DISPOSITION_NEEDS_HUMAN, DISPOSITION_DISPATCHED, DISPOSITION_OPEN], default=DISPOSITION_DISPATCHED)
|
|
m.add_argument("--check", default=None)
|
|
m.add_argument("--comment", default=None)
|
|
m.add_argument("--fetch-file", default=None) # reuse the tick's fetch for the at-mark baseline
|
|
m.add_argument("--acted-edit-id", default=None) # snapshot-time edit_id baseline for a --comment
|
|
m.set_defaults(func=cmd_mark)
|
|
|
|
w = sub.add_parser("watch")
|
|
w.add_argument("--pr", type=int, required=True)
|
|
w.add_argument("--repo", default=None)
|
|
w.add_argument("--state-dir", required=True)
|
|
w.add_argument("--interval", type=float, default=150.0, help="poll cadence seconds")
|
|
w.add_argument("--max-runtime", type=float, default=14400.0, help="hard stop seconds (0 = unbounded)")
|
|
w.add_argument("--settle-seconds", type=float, default=300.0, help="quiet window before a merge-ready wake")
|
|
w.add_argument("--stop-file", default=None, help="path whose existence stops the watch")
|
|
w.add_argument("--fetch-file", default=None)
|
|
w.add_argument("--reset-session", action="store_true") # cmd_watch also forces this on arm
|
|
w.set_defaults(func=cmd_watch)
|
|
|
|
args = p.parse_args()
|
|
args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|