audit: lifecycle hook writes the attempt ledger (gap 3 follow-up)
- cloud/attempt_hook.py (new): canonical policy -- one counted dispatch = one row, is_defect always False from the worker; human/first rounds and human breakpoint resumes count; platform auto-resume reruns, coordinator fan-out, and pre-execution gate failures do not; transient retries inside one dispatch are not new attempts. Platform origin survives via the task_attempt_origin_markers table (same-transaction write on requeue, queued_at generation guard on consume). Defects are explicit-recorder only; corrections are append-only (record_correction, last-wins). - cloud/worker.py: Worker(attempt_ledger_hook=False) + CLI --attempt-ledger-hook (default OFF = gap-3 behaviour exactly); marker write in auto_resume_interrupted, marker consume + classify in run_one, one ledger write at each terminal point (cancel branch, normal terminal block); all hook writes best-effort, never fail the task or block a requeue. - cloud/attempt_ledger.py: corrects_attempt_no column (new DDL supersedes gap 3's pre-merge DDL) + record_correction + correction-aware aggregation; read path uses SELECT * so gap-3-era tables still read; cost code untouched. - cloud/tests/test_attempt_hook.py: 32 tests (policy, markers, corrections, worker integration, mixed endpoint). - docs/arch-conformance-metrics.md: policy, switch, PG manual steps (new PG_DDL + PG_MARKER_DDL), remaining user decisions.
This commit is contained in:
1 parent
436a92ba1d
commit
dc556b0c19
5 files changed
+1522
-38
No files matched your search
@@ -0,0 +1,358 @@
|
||||
"""Lifecycle hook policy for the task-attempt ledger (audit gap 3
|
||||
follow-up: "which dispatch counts as an attempt, and who judges
|
||||
``is_defect``").
|
||||
|
||||
This module is the CANONICAL policy text. The same policy is
|
||||
restated (shorter) in ``docs/arch-conformance-metrics.md`` and
|
||||
pinned by ``cloud/tests/test_attempt_hook.py``; if the three ever
|
||||
disagree, this module wins and the other two must be fixed.
|
||||
|
||||
The problem it solves: audit gap 3 built the ledger but
|
||||
deliberately left the writer unconnected, because a naive writer
|
||||
-- one row per worker execution -- misclassifies exactly twice:
|
||||
it counts a platform auto-resume as an attempt, and it counts a
|
||||
transient retry as a defect. This module fixes the writer's
|
||||
trigger, not the ledger's math: ``aggregate_attempts`` is
|
||||
untouched by the hook (its only change in this track is the
|
||||
correction-row rule in ``attempt_ledger.py``), and the cost
|
||||
dual-column code (``cloud.cost``) is untouched entirely.
|
||||
|
||||
POLICY (each rule is enforced in code below, not just prose):
|
||||
|
||||
1. One counted dispatch = one row, at most. A dispatch that the
|
||||
hook counts writes EXACTLY ONE ledger row, when its outcome is
|
||||
known (terminal ``completed`` / ``failed`` / ``interrupted``,
|
||||
or the goal-conversion case in ``worker.run_one``), with
|
||||
``is_defect=False`` always (rule 6). A dispatch that the hook
|
||||
does not count writes NO row at all -- not even an
|
||||
``interrupted`` row -- so an uncounted dispatch can never
|
||||
create the ledger table as a side effect.
|
||||
|
||||
2. What counts as one attempt -- a logical agent round, not a
|
||||
worker execution:
|
||||
- First dispatch of a task: counts (``first_or_human_retry``).
|
||||
It is the task's first agent round.
|
||||
- Human ``/retry`` of a failed/interrupted/merge-failed task:
|
||||
counts (same kind). A retry is a human-initiated new round:
|
||||
new model work, new result, new cost. The server endpoint
|
||||
leaves no marker behind, so the worker cannot distinguish a
|
||||
retry from a first dispatch -- and does not need to, because
|
||||
both count.
|
||||
- Human ``/continue`` with a new message: counts
|
||||
(``human_continue``). The message starts a new round on the
|
||||
same session thread. Non-goal tasks and goal tasks opening a
|
||||
NEW goal loop (``_resume_kind == "new-goal"``) are both here.
|
||||
- Human breakpoint resume (``/goal-resume``, or ``/continue``
|
||||
on an old-verify goal task, i.e. ``_resume_kind ==
|
||||
"breakpoint"``): counts (``breakpoint_resume``) -- but as
|
||||
the SAME logical round's row, not as an extra attempt. The
|
||||
fragment that was interrupted wrote only an excluded
|
||||
``interrupted`` row (gap 3 rule), so the resume's terminal
|
||||
row is the round's single counted row. A resume that ends
|
||||
interrupted again writes only another excluded row: the
|
||||
round is still pending, and still contributes zero counted
|
||||
attempts. Rationale: one goal loop = one attempt, however
|
||||
many executions the loop took; the interruption history
|
||||
stays visible in the ``interrupted`` rows and in
|
||||
``auto_resume_count`` / audit / task_steps, not in the
|
||||
attempt count. EXCEPTION (ordering, rule 4): when the
|
||||
resume was itself platform-triggered, the platform marker
|
||||
wins and the dispatch does NOT count -- a platform goal
|
||||
auto-resume and a human goal-resume are the same loop
|
||||
continuation, but only the human one is attributed to the
|
||||
agent. That asymmetry is deliberate and uniform with the
|
||||
platform rule below; flipping it is the listed user
|
||||
decision, not a silent default.
|
||||
- Coordinator parent dispatch: does NOT count
|
||||
(``coordinator_orchestration``). The parent runs no model
|
||||
round (``cost_record_id`` is None) and its terminal status
|
||||
is an aggregate of its children; each child records its own
|
||||
row through its own dispatch. Counting the parent would
|
||||
double-count orchestration as an agent attempt.
|
||||
- Platform auto-resume dispatch (worker
|
||||
``auto_resume_interrupted`` requeue, goal or non-goal):
|
||||
does NOT count (``platform_auto_resume``). This is the
|
||||
default the gap-3 track demanded, and it is UNIFORM: every
|
||||
platform requeue is excluded, whether it continues a goal
|
||||
loop or fresh-runs a non-goal task. Rationale: the platform
|
||||
killed the previous execution and re-ran it without any
|
||||
human or coordinator decision; attributing that execution
|
||||
to the agent would punish platform instability in an
|
||||
agent-quality rate. TRADE-OFF, STATED NOT HIDDEN: a task
|
||||
whose terminal outcome came ONLY from platform dispatches
|
||||
stays unknown in the ledger (no counted row), so it is
|
||||
excluded from rate denominators -- the rates describe
|
||||
tasks whose terminal outcome came from a human/first
|
||||
round. The platform reruns themselves are NOT lost: they
|
||||
are recorded in ``tasks.auto_resume_count``, the
|
||||
``worker_auto_resumed`` audit events, and the
|
||||
``task_steps`` ``auto_resume`` rows. The rejected
|
||||
alternative was a separately marked ledger row that is
|
||||
excluded from denominators: same denominator outcome, but
|
||||
a second row kind, a new column, and aggregate exclusion
|
||||
logic, for co-location only. If a future policy wants
|
||||
platform completions counted as loop completions, the flip
|
||||
is one line in ``classify_dispatch`` (platform + breakpoint
|
||||
-> counted) plus tests -- it is listed as a user decision
|
||||
in the track summary, not silently decided here.
|
||||
- Pre-execution gate failures: do NOT count. A dispatch that
|
||||
never reached the model -- missing key, repository
|
||||
resolution failure, quota gate, goal-rounds cap, gateway
|
||||
preparation failure -- is an admission rejection, not an
|
||||
attempt. It writes no row (and no cost record either, which
|
||||
is the existing behaviour); it stays visible in audit
|
||||
(``worker_missing_key`` / ``worker_repo_failed`` /
|
||||
``worker_quota_stop`` / ``worker_goal_cap_rejected`` /
|
||||
``worker_gateway_failed``).
|
||||
- Dispatches that never ran (budget gate, no ready task,
|
||||
claim lost to another worker): do NOT count and are not
|
||||
even classified -- ``run_one`` returns before the hook
|
||||
runs, and no marker is consumed (see rule 4).
|
||||
|
||||
3. Transient retries inside one dispatch are NOT new attempts.
|
||||
The worker's ``max_retries`` loop, its one real fallback
|
||||
re-run, its goal "already exists" breakpoint fallback, and
|
||||
its follow-up drain all run inside a single ``run_one``
|
||||
dispatch, share one cost aggregate, and produce ONE ledger
|
||||
row: the dispatch's terminal outcome. An inner failure that
|
||||
is later retried successfully leaves no failure row behind;
|
||||
an inner failure that exhausts retries leaves one failure
|
||||
row, not N. This is enforced by placement (the hook is
|
||||
called once per dispatch, at ``run_one``'s terminal points)
|
||||
rather than by counting executions.
|
||||
|
||||
4. Platform identification -- the marker mechanism. The worker
|
||||
that requeues a task is not necessarily the worker that later
|
||||
claims it, so the origin must survive in the database, not in
|
||||
memory. ``auto_resume_interrupted`` writes one marker row
|
||||
(``task_attempt_origin_markers``) in the SAME transaction as
|
||||
the requeue, with ``queued_at`` equal to the ``updated_at``
|
||||
the requeue writes. ``run_one`` captures the queued task's
|
||||
``updated_at`` BEFORE claiming (claim overwrites it), and
|
||||
after a successful claim consumes the marker ONLY if
|
||||
``queued_at`` still matches. Any intervening human action
|
||||
(cancel, retry, continue) rewrites ``updated_at``, so a stale
|
||||
marker can never misclassify a later human round: on
|
||||
mismatch the marker is deleted as stale and the dispatch is
|
||||
classified by its own fields. A claim that loses the race
|
||||
consumes nothing, so the winning worker still sees its
|
||||
marker. Marker writes and reads happen ONLY when the hook is
|
||||
enabled; with the hook off, ``auto_resume_interrupted``
|
||||
writes no marker and ``run_one`` reads none, so off behaviour
|
||||
is byte-identical to gap 3 (no new table, no new row).
|
||||
Marker-table creation is a write-path-only step, like the
|
||||
ledger table itself; the read path (``GET /metrics-report``)
|
||||
never touches it.
|
||||
|
||||
5. Switch. ``Worker(attempt_ledger_hook=False)`` (CLI
|
||||
``--attempt-ledger-hook``), default OFF. OFF means: no ledger
|
||||
row, no marker row, no marker-table creation, no ledger-table
|
||||
creation from the worker -- exactly gap 3's "explicit calls
|
||||
only" behaviour, including old tasks still returning ``None``
|
||||
(unknown, not 0) and the read endpoint never creating a
|
||||
table. ON means rules 1-4 apply. Enabling the hook
|
||||
mid-flight has one documented edge: a task requeued by
|
||||
auto-resume WHILE the hook was off carries no marker, so its
|
||||
next dispatch counts as a human/first round (one extra
|
||||
counted row for that in-flight task, once). That is the
|
||||
fail-safe direction (a human round is the common case), and
|
||||
it self-heals: every later requeue writes a marker.
|
||||
|
||||
6. ``is_defect`` -- who judges, and how a wrong judgement is
|
||||
fixed. The worker hook NEVER judges a defect: it always
|
||||
records ``is_defect=False``. A failed dispatch is not a
|
||||
defect (transient failures, quota stops, and cancelled rounds
|
||||
all fail without being defects), and the worker cannot tell
|
||||
a transient failure from a defect round at runtime -- so it
|
||||
must not guess. The ONLY defect judgements in the ledger are
|
||||
explicit ones, made at record time by an explicit recorder:
|
||||
a human maintainer (or a coordinator review) calling
|
||||
``record_attempt`` / ``record_correction`` directly with
|
||||
``is_defect=True`` and a category. The bar for that authority
|
||||
is maintainer-level (``approve_merge``, the same bar as
|
||||
``/retry``: both spend model money and move decision-grade
|
||||
rates). A defect judgement made in error is corrected ONLY by
|
||||
appending a correction row (``record_correction``): the old
|
||||
row is never updated or deleted, corrections apply in
|
||||
attempt order with last-wins, and a correction can only
|
||||
change the defect judgement, never the outcome. There is NO
|
||||
HTTP endpoint for marking or correcting defects in this
|
||||
track -- the function is the interface, and an endpoint (with
|
||||
the ``approve_merge`` permission check) is follow-up work,
|
||||
listed as unconnected in the track summary. Until it exists,
|
||||
defect marks come only from explicit, reviewed calls, never
|
||||
from automation.
|
||||
|
||||
7. Failure discipline. Every hook write is best-effort: a ledger
|
||||
or marker failure is logged (``worker_attempt_ledger_failed``
|
||||
/ ``worker_attempt_marker_failed``) and NEVER fails the task,
|
||||
blocks a requeue, or changes a task's status. The one
|
||||
exception is validation inside ``record_attempt`` /
|
||||
``record_correction`` (bad outcome, defect on an interrupted
|
||||
row, correcting a nonexistent attempt): those raise
|
||||
``ValueError`` BEFORE writing, by design, so ambiguous data
|
||||
is never stored -- but the worker hook only ever passes
|
||||
validated values (a terminal outcome, ``is_defect=False``),
|
||||
so it cannot hit that path in normal operation.
|
||||
|
||||
8. Postgres. The marker table follows the ledger table's manual
|
||||
discipline: it is NOT created by ``init_postgres_schema``,
|
||||
and ``PG_MARKER_DDL`` below is the exact statement to run by
|
||||
hand on production Postgres (and verify) before enabling the
|
||||
hook against a Postgres-backed control plane. Until that
|
||||
step runs, marker writes fail (best-effort, rule 7) and
|
||||
platform dispatches degrade to counted -- the same
|
||||
fail-safe direction as rule 5's mid-flight edge, and the
|
||||
reason the PG manual step is a precondition for enabling
|
||||
the hook in production, not an afterthought. Real Postgres
|
||||
verification was NOT done on this track (no local Postgres):
|
||||
PG evidence here is adapter-level only (``?`` placeholders
|
||||
translate to ``%s`` through ``cloud.db``, DDL uses only
|
||||
Postgres-valid types, no autoincrement/identity column),
|
||||
exactly like gap 3's PG evidence, and labelled as such.
|
||||
|
||||
This module imports ``cloud.attempt_ledger`` only, opens no
|
||||
database connection itself (callers pass a connection in), and
|
||||
contains no ``sqlite3.connect`` / ``psycopg*`` import, so the
|
||||
``arch_conformance`` layer rules are untouched.
|
||||
"""
|
||||
|
||||
from cloud.attempt_ledger import record_attempt
|
||||
|
||||
MARKER_TABLE = "task_attempt_origin_markers"
|
||||
|
||||
# One DDL serves both backends: TEXT / DOUBLE PRECISION / composite
|
||||
# primary key, no AUTOINCREMENT or identity column -- the same
|
||||
# discipline as the ledger DDL. ``queued_at`` is the ``updated_at``
|
||||
# the requeue wrote; it is the generation guard, not a timestamp
|
||||
# for display.
|
||||
MARKER_DDL = (
|
||||
"CREATE TABLE IF NOT EXISTS task_attempt_origin_markers("
|
||||
"task_id TEXT NOT NULL PRIMARY KEY, "
|
||||
"origin TEXT NOT NULL, "
|
||||
"queued_at DOUBLE PRECISION NOT NULL)"
|
||||
)
|
||||
|
||||
# Production Postgres manual step (see rule 8). Intentionally NOT
|
||||
# part of POSTGRES_SCHEMA, same reason as the ledger PG_DDL.
|
||||
PG_MARKER_DDL = MARKER_DDL
|
||||
|
||||
PLATFORM_ORIGIN = "platform_auto_resume"
|
||||
|
||||
# Decision kinds returned by classify_dispatch. ``counted`` is the
|
||||
# only field the worker acts on; ``kind`` is the audit label.
|
||||
KINDS = frozenset({
|
||||
"coordinator_orchestration",
|
||||
"platform_auto_resume",
|
||||
"breakpoint_resume",
|
||||
"human_continue",
|
||||
"first_or_human_retry",
|
||||
})
|
||||
|
||||
|
||||
def classify_dispatch(task, platform_marker):
|
||||
"""Pure policy: task fields + marker -> decision dict.
|
||||
|
||||
``task`` is the dispatch's task dict (post-claim, post
|
||||
breakpoint resolution: ``_goal_resume_breakpoint`` is True
|
||||
only when a breakpoint resume actually applies, False when
|
||||
resolution fell back to a fresh goal run). ``platform_marker``
|
||||
is True when this dispatch consumed a platform auto-resume
|
||||
marker for the queued generation (rule 4), else False.
|
||||
|
||||
Returns ``{"counted": bool, "kind": str}``. Order matters
|
||||
and is part of the policy: coordinator fan-out never counts
|
||||
even with a marker; a platform marker beats every other
|
||||
signal (uniform platform exclusion, rule 2); a breakpoint
|
||||
resume beats a continue message (a stale resume_message on
|
||||
a breakpoint task is not a new round); anything else with a
|
||||
resume message is a human continue; the rest is a first
|
||||
dispatch or a human retry, which count identically.
|
||||
"""
|
||||
task = task or {}
|
||||
if task.get("coordinator_parts"):
|
||||
return {"counted": False,
|
||||
"kind": "coordinator_orchestration"}
|
||||
if platform_marker:
|
||||
return {"counted": False, "kind": "platform_auto_resume"}
|
||||
if task.get("_goal_resume_breakpoint") is True or (
|
||||
task.get("_resume_kind") == "breakpoint"):
|
||||
return {"counted": True, "kind": "breakpoint_resume"}
|
||||
if task.get("resume_message"):
|
||||
return {"counted": True, "kind": "human_continue"}
|
||||
return {"counted": True, "kind": "first_or_human_retry"}
|
||||
|
||||
|
||||
def ensure_marker_table(conn):
|
||||
"""Create the marker table if absent. Idempotent; the caller
|
||||
owns the commit (``mark_platform_rerun`` commits via the
|
||||
caller's requeue transaction in the worker; a bare ensure
|
||||
inside a larger transaction does not commit)."""
|
||||
conn.execute(MARKER_DDL)
|
||||
|
||||
|
||||
def mark_platform_rerun(conn, task_id, queued_at):
|
||||
"""Record that ``task_id`` was requeued by platform auto-resume
|
||||
at ``queued_at`` (the requeue's ``updated_at``). Overwrites
|
||||
any marker for the task: a requeue supersedes any older
|
||||
generation. Callers must call this inside the requeue
|
||||
transaction (the worker does: same connection, same commit
|
||||
as the status change), so a marker can never exist without
|
||||
its queued state, or vice versa. Best-effort at the call
|
||||
site: on failure the caller logs and proceeds with the
|
||||
requeue (rule 7) -- a missing marker degrades to counted,
|
||||
never to a blocked requeue."""
|
||||
if not task_id:
|
||||
raise ValueError("task_id is required")
|
||||
ensure_marker_table(conn)
|
||||
conn.execute(
|
||||
"DELETE FROM task_attempt_origin_markers WHERE task_id = ?",
|
||||
(task_id,),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO task_attempt_origin_markers(task_id, origin, "
|
||||
"queued_at) VALUES(?, ?, ?)",
|
||||
(task_id, PLATFORM_ORIGIN, queued_at),
|
||||
)
|
||||
|
||||
|
||||
def consume_platform_marker(conn, task_id, queued_at):
|
||||
"""Consume the platform marker for ``task_id`` iff its
|
||||
``queued_at`` still matches this dispatch's queued
|
||||
``updated_at``. Returns True on a match (this dispatch IS
|
||||
the platform rerun). On mismatch the marker belongs to an
|
||||
older generation (a human action rewrote ``updated_at``
|
||||
since) and is deleted as stale; returns False. When
|
||||
``queued_at`` is None the generation cannot be verified, so
|
||||
nothing is consumed or deleted and False is returned (the
|
||||
dispatch then counts -- the rule-5 fail-safe direction).
|
||||
A missing marker table raises the backend's OperationalError
|
||||
(loud, not silent): the worker call site treats that as
|
||||
"no marker" and logs it, it never fails the dispatch."""
|
||||
if not task_id or queued_at is None:
|
||||
return False
|
||||
cur = conn.execute(
|
||||
"DELETE FROM task_attempt_origin_markers "
|
||||
"WHERE task_id = ? AND queued_at = ?",
|
||||
(task_id, queued_at),
|
||||
)
|
||||
if cur.rowcount == 1:
|
||||
return True
|
||||
conn.execute(
|
||||
"DELETE FROM task_attempt_origin_markers WHERE task_id = ?",
|
||||
(task_id,),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def record_dispatch_result(conn, task_id, outcome):
|
||||
"""Write the ledger row for one COUNTED dispatch. The worker
|
||||
calls this only when ``classify_dispatch`` said counted, and
|
||||
only with a terminal outcome (``success`` / ``failure`` /
|
||||
``interrupted``). ``is_defect`` is always False here (rule
|
||||
6): the worker never judges a defect. Returns the new
|
||||
``attempt_no``. The counted filter lives in the caller
|
||||
(``classify_dispatch``); this function takes the outcome
|
||||
only, so a miscounted call site cannot smuggle a defect
|
||||
judgement in through here either."""
|
||||
return record_attempt(conn, task_id, outcome, is_defect=False)
|
||||
Reference in new issue
Block a user