b519ebd98f
The master-side state machine driver. When a worker writes a complete
(or failed) attempt, the tick handler picks it up next pass, maps the
outcome to a state machine event, and applies the transition.
tools/controller/master/:
- outcomes.py: map_outcome_to_event() — pure function. Inputs:
role, current_state, output_payload, status, head_sha_advanced,
attempts_remaining_at_tier, conflict_count_at_current_tier.
Returns EventMapResult(event_name|None, reason).
- Implementer: resolved+pushed → implementer_pushed;
resolved+NO push → implementer_competence_failure (worker lied);
rebase-failed / competence-failure / blocked / noop all routed.
- Reviewer: approve → reviewer_approve; request-changes → retry vs
escalate based on attempts_remaining_at_tier; abstain →
reviewer_abstain; comment → no transition (operator review needed).
- Estimator: is_metadata_only → estimator_metadata_only; else
estimator_done.
- Conflict resolver: counts at current tier — 1st → first; 2nd →
second_same_tier (escalate); 3rd+ → three_plus (STUCK).
- Summarizer: doesn't drive state transitions.
- status='failed' policy: contract-violation → pickup_exhausted;
other failed outcomes (worker-internal-error / stale-input /
ttl-insufficient-for-retry / git-clone-failed / lost-lock / etc.)
→ no event (master re-enqueues per the v9 table).
- status='reaped' → no event (pickup guard handled).
- tick.py: run_tick() — one master tick. Queries
workflow_attempts WHERE status IN ('complete', 'failed') AND
finished_at > workflow.last_transition_at (heuristic for "not yet
processed"). Per row:
1. Validate current_state ∈ KNOWN_STATES (per v6 unknown-state
guard); if not, transition workflow → STUCK with reason='unknown-state'.
2. Map outcome → event via outcomes.map_outcome_to_event.
3. If no event, bump workflow.last_transition_at so we don't
re-process forever.
4. Apply event via state_machine.apply_event; IllegalTransition →
STUCK with reason='illegal-event'.
5. Commit transition (workflows.current_state + entered_state_at +
last_transition_at) AND insert controller_events row.
Returns TickReport with attempts_processed + transitions_applied +
transitions_log.
42 new tests:
- outcomes (22): status='failed' policy (3 paths) + each role's
happy paths + edge cases (unknown outcome / missing field /
None payload / unknown role).
- tick (20): implementer transitions (resolved+push → AWAITING_CI,
resolved-no-push → ESCALATING, rebase → CONFLICT_RESOLVING,
blocked → STUCK), reviewer transitions (approve → MERGING,
request-changes → IMPLEMENTING), event row content, no
reprocessing on second tick, unmapped attempts bump
last_transition_at, terminal workflows skipped (4 parametrized),
unknown current_state → STUCK.
Total: 271 controller tests; full auto_agents suite 2633 pass.
300 lines
10 KiB
Python
300 lines
10 KiB
Python
"""Master tick handler — advances state machine for completed attempts.
|
|
|
|
One tick:
|
|
1. Query workflow_attempts where status='complete' AND workflow has
|
|
NOT yet processed this attempt (we track via a "consumed_at"
|
|
column? No — for v1 we use a simpler heuristic: an attempt is
|
|
"unprocessed" if its workflow_attempts.finished_at is more recent
|
|
than the workflow's last_transition_at).
|
|
2. For each, look up the workflow + use ``map_outcome_to_event`` to
|
|
pick a state machine event.
|
|
3. Apply via ``apply_event``; update workflow.current_state;
|
|
insert controller_events row.
|
|
|
|
What's deliberately deferred to Phase 1d-3:
|
|
- Discovery (Forgejo poll for new PRs/issues)
|
|
- Forgejo writes (status comments, labels, merges)
|
|
- Per-workflow "what to enqueue next" logic (after a state
|
|
transition, the master schedules the next attempt; for v1
|
|
this commit only handles the transition itself)
|
|
- Reconciliation tick (DB↔Forgejo sync)
|
|
- MERGING state's Forgejo merge call
|
|
|
|
This tick is composable: the master's main loop will call this tick,
|
|
then the reaper, then the pickup guard, then sleep + repeat.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import text
|
|
from sqlalchemy.engine import Engine
|
|
|
|
from ..db.session import session_scope
|
|
from ..state_machine import (
|
|
IllegalTransitionError,
|
|
KNOWN_STATES,
|
|
TERMINAL_STATES,
|
|
apply_event,
|
|
)
|
|
from .outcomes import EventMapResult, map_outcome_to_event
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class TickReport:
|
|
"""Per-tick summary."""
|
|
|
|
attempts_processed: int = 0
|
|
transitions_applied: int = 0
|
|
unmapped_attempts: list[tuple[int, str]] = field(default_factory=list)
|
|
# (attempt_id, reason) — operator-visible via tail-events
|
|
|
|
transitions_log: list[dict] = field(default_factory=list)
|
|
# Each entry: {workflow_id, from_state, to_state, event, attempt_id}
|
|
|
|
|
|
def run_tick(engine: Engine) -> TickReport:
|
|
"""One master tick: advance state for any complete attempts not
|
|
yet processed."""
|
|
report = TickReport()
|
|
now = datetime.now(timezone.utc)
|
|
with session_scope(engine) as session:
|
|
rows = session.execute(
|
|
text(
|
|
"SELECT a.attempt_id, a.workflow_id, a.role, a.tier, "
|
|
" a.output_payload, a.status, "
|
|
" a.head_sha_before, a.head_sha_after, "
|
|
" a.pickup_count, "
|
|
" w.current_state, w.current_tier, w.last_transition_at "
|
|
" FROM workflow_attempts a "
|
|
" JOIN workflows w ON w.workflow_id = a.workflow_id "
|
|
" WHERE a.status IN ('complete', 'failed') "
|
|
" AND a.finished_at IS NOT NULL "
|
|
" AND a.finished_at > w.last_transition_at "
|
|
" AND w.current_state NOT IN ('MERGED', 'ABANDONED', 'STUCK', 'CREATED_PR') "
|
|
" ORDER BY a.finished_at ASC"
|
|
)
|
|
).all()
|
|
|
|
for r in rows:
|
|
report.attempts_processed += 1
|
|
transitioned = _process_attempt(session, r, now)
|
|
if transitioned is None:
|
|
# No mappable event; log + skip.
|
|
continue
|
|
report.transitions_applied += 1
|
|
report.transitions_log.append(transitioned)
|
|
|
|
return report
|
|
|
|
|
|
# ─── per-row processing ───────────────────────────────────────────────
|
|
|
|
|
|
def _process_attempt(session, row, now: datetime) -> dict | None:
|
|
"""Apply one attempt's outcome to its workflow's state machine.
|
|
|
|
Returns the transition record on success, or None if no
|
|
transition was applied (unmapped outcome, illegal event, etc.).
|
|
"""
|
|
# 1. Validate the workflow's current state.
|
|
if row.current_state not in KNOWN_STATES:
|
|
logger.error(
|
|
"workflow %s has unknown current_state %r; transitioning to STUCK",
|
|
row.workflow_id, row.current_state,
|
|
)
|
|
_transition_to_stuck(
|
|
session, row.workflow_id, row.current_state, now,
|
|
reason="unknown-state",
|
|
attempt_id=row.attempt_id,
|
|
)
|
|
return {
|
|
"workflow_id": row.workflow_id,
|
|
"from_state": row.current_state,
|
|
"to_state": "STUCK",
|
|
"event": "(synthetic) unknown-state",
|
|
"attempt_id": row.attempt_id,
|
|
}
|
|
|
|
# 2. Decode output_payload + compute auxiliary context.
|
|
output_payload = _decode_output_payload(row.output_payload)
|
|
head_sha_advanced = (
|
|
bool(row.head_sha_after)
|
|
and bool(row.head_sha_before)
|
|
and row.head_sha_after != row.head_sha_before
|
|
)
|
|
|
|
# 3. Map outcome → event.
|
|
# attempts_remaining_at_tier + conflict_count_at_current_tier:
|
|
# for v1 we use a simple computation (count of attempts at this
|
|
# role+tier vs max_attempts on the workflow). The full policy
|
|
# lives in Phase 1d-3; v1 ships with a "≥1 remaining always"
|
|
# default which keeps the state machine in a simple loop until
|
|
# max_attempts kicks in via the pickup guard.
|
|
attempts_remaining_at_tier = 1
|
|
conflict_count_at_current_tier = (
|
|
_count_conflict_resolver_attempts(
|
|
session, row.workflow_id, row.current_tier,
|
|
)
|
|
)
|
|
|
|
mapped: EventMapResult = map_outcome_to_event(
|
|
role=row.role,
|
|
current_state=row.current_state,
|
|
output_payload=output_payload,
|
|
status=row.status,
|
|
head_sha_advanced=head_sha_advanced,
|
|
attempts_remaining_at_tier=attempts_remaining_at_tier,
|
|
conflict_count_at_current_tier=conflict_count_at_current_tier,
|
|
)
|
|
if mapped.event_name is None:
|
|
# Bump the workflow's last_transition_at so we don't re-process
|
|
# the same attempt every tick.
|
|
session.execute(
|
|
text(
|
|
"UPDATE workflows SET last_transition_at = :now "
|
|
"WHERE workflow_id = :wf_id"
|
|
),
|
|
{"now": now, "wf_id": row.workflow_id},
|
|
)
|
|
logger.debug(
|
|
"attempt_id=%s no mappable event: %s",
|
|
row.attempt_id, mapped.reason,
|
|
)
|
|
return None
|
|
|
|
# 4. Apply the event.
|
|
try:
|
|
to_state = apply_event(row.current_state, mapped.event_name)
|
|
except IllegalTransitionError as exc:
|
|
logger.warning(
|
|
"illegal transition for workflow %s: %s (event=%s); "
|
|
"transitioning to STUCK",
|
|
row.workflow_id, exc, mapped.event_name,
|
|
)
|
|
_transition_to_stuck(
|
|
session, row.workflow_id, row.current_state, now,
|
|
reason=f"illegal-event: {mapped.event_name}",
|
|
attempt_id=row.attempt_id,
|
|
)
|
|
return {
|
|
"workflow_id": row.workflow_id,
|
|
"from_state": row.current_state,
|
|
"to_state": "STUCK",
|
|
"event": mapped.event_name,
|
|
"attempt_id": row.attempt_id,
|
|
}
|
|
|
|
# 5. Commit the transition.
|
|
session.execute(
|
|
text(
|
|
"UPDATE workflows SET "
|
|
" current_state = :to_state, "
|
|
" last_transition_at = :now, "
|
|
" entered_state_at = :now "
|
|
"WHERE workflow_id = :wf_id"
|
|
),
|
|
{"to_state": to_state, "now": now, "wf_id": row.workflow_id},
|
|
)
|
|
session.execute(
|
|
text(
|
|
"INSERT INTO controller_events "
|
|
"(workflow_id, ts, event_type, from_state, to_state, "
|
|
" attempt_id, payload, forgejo_write_pending, replay_attempts) "
|
|
"VALUES (:wf_id, :ts, 'transition', :from_state, :to_state, "
|
|
" :aid, :payload, 0, 0)"
|
|
),
|
|
{
|
|
"wf_id": row.workflow_id,
|
|
"ts": now,
|
|
"from_state": row.current_state,
|
|
"to_state": to_state,
|
|
"aid": row.attempt_id,
|
|
"payload": json.dumps({
|
|
"event": mapped.event_name,
|
|
"reason": mapped.reason,
|
|
"role": row.role,
|
|
}),
|
|
},
|
|
)
|
|
return {
|
|
"workflow_id": row.workflow_id,
|
|
"from_state": row.current_state,
|
|
"to_state": to_state,
|
|
"event": mapped.event_name,
|
|
"attempt_id": row.attempt_id,
|
|
}
|
|
|
|
|
|
# ─── helpers ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def _decode_output_payload(raw) -> dict | None:
|
|
"""SQLite returns JSON columns as strings (when bound via text());
|
|
Postgres returns dicts. Normalize."""
|
|
if raw is None:
|
|
return None
|
|
if isinstance(raw, str):
|
|
try:
|
|
return json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
return raw
|
|
|
|
|
|
def _count_conflict_resolver_attempts(
|
|
session, workflow_id: int, current_tier: int | None,
|
|
) -> int:
|
|
"""Count CONFLICT_RESOLVING attempts at the current tier."""
|
|
if current_tier is None:
|
|
return 0
|
|
row = session.execute(
|
|
text(
|
|
"SELECT COUNT(*) AS n FROM workflow_attempts "
|
|
"WHERE workflow_id = :wf_id AND role = 'conflict_resolver' "
|
|
" AND tier = :tier"
|
|
),
|
|
{"wf_id": workflow_id, "tier": current_tier},
|
|
).first()
|
|
return row.n if row else 0
|
|
|
|
|
|
def _transition_to_stuck(
|
|
session, workflow_id: int, from_state: str, now: datetime, *,
|
|
reason: str, attempt_id: int | None,
|
|
) -> None:
|
|
session.execute(
|
|
text(
|
|
"UPDATE workflows SET "
|
|
" current_state = 'STUCK', "
|
|
" last_transition_at = :now, "
|
|
" entered_state_at = :now "
|
|
"WHERE workflow_id = :wf_id"
|
|
),
|
|
{"now": now, "wf_id": workflow_id},
|
|
)
|
|
session.execute(
|
|
text(
|
|
"INSERT INTO controller_events "
|
|
"(workflow_id, ts, event_type, from_state, to_state, "
|
|
" attempt_id, payload, forgejo_write_pending, replay_attempts) "
|
|
"VALUES (:wf_id, :ts, 'transition', :from_state, 'STUCK', "
|
|
" :aid, :payload, 0, 0)"
|
|
),
|
|
{
|
|
"wf_id": workflow_id,
|
|
"ts": now,
|
|
"from_state": from_state,
|
|
"aid": attempt_id,
|
|
"payload": json.dumps({"reason": reason}),
|
|
},
|
|
)
|
|
|
|
|
|
__all__ = ["TickReport", "run_tick"]
|