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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -998,11 +998,11 @@ framework is an extra.
|---|---|
| **Design** | Thesis, 28 binding decisions, FRD (120 requirements), 60-row refusal ledger — complete |
| **Research** | 14 source-grounded studies, ~15,750 lines, over 140 repos (~15 GB) + 57 papers |
| **Code** | Loader, diagnostics, schema engine, CLI, harness, resolver, evals, SLO — **4813 tests (1623 Rust + 3190 adapter), clippy clean, TypeScript type-checked** |
| **Code** | Loader, diagnostics, schema engine, CLI, harness, resolver, evals, SLO — **4822 tests (1623 Rust + 3199 adapter), clippy clean, TypeScript type-checked** |
| **Adapters** | **All 7 named targets**, proven against one shared conformance suite |

```bash
./scripts/test-all.sh # 4813 tests, Rust + 7 adapters, fully offline
./scripts/test-all.sh # 4822 tests, Rust + 7 adapters, fully offline
```

**What is still owed** is not hidden — it lives in
Expand Down
16 changes: 13 additions & 3 deletions adapters/python/src/pact_adapters/at_most_once.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,8 +321,17 @@ class Ledger:
#: The team a `the-team` claim is namespaced by: the agent at the root of
#: the request, handed down to every member.
team: str = ""
#: Who holds what this run claims, in the store: this run, by name.
#: The workspace a `the-workspace` claim is namespaced by, when the host
#: names one; empty, one namespace for everything the store holds.
workspace: str = ""
#: Who holds what this run claims, in the store: this run, by name. A parked
#: run comes back with the same holder (`Suspension.claim_holder`), so the
#: claims it took before the park are still its own.
holder: str = ""
#: The fencing token of each claim this run took in the store, by
#: `(tool, action, value)`: what a host hands the system a call writes to,
#: so a writer whose lease ended while it ran can be told it is stale.
fences: dict[tuple[str, str, str], int] = field(default_factory=dict)
#: One sentence per `<tool>/<action>` whose wider scope had no store.
cannot_keep: dict[tuple[str, str], str] = field(default_factory=dict)

Expand Down Expand Up @@ -374,12 +383,13 @@ def hold(self, tool: str, args: Mapping[str, Any]) -> str:
self.cannot_keep.setdefault((tool, claim.action), claim.kept_here_only())
else:
taken = self.store.take(
scoped(claim.across, self.team, tool, claim.action, claim.value),
scoped(claim.across, self.team, tool, claim.action, claim.value, self.workspace),
self.holder,
self.keys.leases.get((tool, claim.action)),
)
if not taken.ok:
return claim.refusal()
self.fences[claim.key] = taken.fence
self.spent[claim.key] = None
return ""

Expand All @@ -405,7 +415,7 @@ def look(self, tool: str, args: Mapping[str, Any]) -> str:
return claim.refusal()
if claim.across != "this-run" and self.store is not None:
held = self.store.holder(
scoped(claim.across, self.team, tool, claim.action, claim.value)
scoped(claim.across, self.team, tool, claim.action, claim.value, self.workspace)
)
if held is not None and held != self.holder:
return claim.refusal()
Expand Down
79 changes: 61 additions & 18 deletions adapters/python/src/pact_adapters/claims.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@
rather than an invented semantics). A claim with no lease lasts as long as the
store keeps it.

**The fencing token.** Every grant of a key to a holder that does not already
hold it is numbered one higher than the key's grant before it; a renewal keeps
the number. A lease bounds how long a claim lasts but cannot stop a call that is
already running when it ends, so a worker that stalled past its lease and then
writes would be a second writer nobody refused. The number is what the system it
writes to refuses it with: a write carrying a token lower than one it has seen
is stale. `Taken.fence` is the number, and an expired or released key keeps its
count so the next grant is higher still.

**What is deliberately not here.** Releasing a claim early is `release`, called
by whoever holds it; nothing releases somebody else's. There is no "who holds
this" query on the run's side: the refusal says the key is taken, and the
Expand All @@ -43,11 +52,13 @@
@dataclass(frozen=True)
class Taken:
"""The answer to `take`: whether this caller now holds the key, and if not,
who does and until when (`None`: no lease)."""
who does and until when (`None`: no lease); and the fencing token of the
grant that holds it."""

ok: bool
holder: str = ""
until: float | None = None
fence: int = 0


class ClaimStore(Protocol):
Expand All @@ -57,7 +68,9 @@ def take(self, key: str, holder: str, lease: float | None) -> Taken:
"""Claim `key` for `holder` for `lease` seconds (`None`: no expiry).

Taking a key you already hold succeeds and renews the lease — a worker
sending its heartbeat. Taking one somebody else holds, unexpired, fails.
sending its heartbeat — keeping its fencing token. Taking one somebody
else holds, unexpired, fails. Any other take is a new grant, numbered
one higher than the key's grant before it.
"""
...

Expand All @@ -75,6 +88,9 @@ class MemoryClaims:

def __init__(self, clock: Callable[[], float] = time.time) -> None:
self._held: dict[str, tuple[str, float | None]] = {}
# Kept when a claim expires or is released, so a later grant's token is
# higher than every token the key was granted with before.
self._fences: dict[str, int] = {}
self._lock = threading.Lock()
self._clock = clock

Expand All @@ -91,11 +107,15 @@ def _live(self, key: str) -> tuple[str, float | None] | None:
def take(self, key: str, holder: str, lease: float | None) -> Taken:
with self._lock:
found = self._live(key)
fence = self._fences.get(key, 0)
if found is not None and found[0] != holder:
return Taken(ok=False, holder=found[0], until=found[1])
return Taken(ok=False, holder=found[0], until=found[1], fence=fence)
if found is None:
fence += 1
self._fences[key] = fence
until = None if lease is None else self._clock() + lease
self._held[key] = (holder, until)
return Taken(ok=True, holder=holder, until=until)
return Taken(ok=True, holder=holder, until=until, fence=fence)

def release(self, key: str, holder: str) -> bool:
with self._lock:
Expand All @@ -120,8 +140,14 @@ def __init__(self, path: "str | Path", clock: Callable[[], float] = time.time) -
with self._connect() as db:
db.execute(
"CREATE TABLE IF NOT EXISTS claims ("
" key TEXT PRIMARY KEY, holder TEXT NOT NULL, until REAL)"
" key TEXT PRIMARY KEY, holder TEXT NOT NULL, until REAL,"
" fence INTEGER NOT NULL DEFAULT 0)"
)
# A file written before the fencing token gains the column; its
# rows count as never granted, so their next grant is token 1.
columns = {row[1] for row in db.execute("PRAGMA table_info(claims)")}
if "fence" not in columns:
db.execute("ALTER TABLE claims ADD COLUMN fence INTEGER NOT NULL DEFAULT 0")

def _connect(self) -> sqlite3.Connection:
# `isolation_level=None` so `BEGIN IMMEDIATE` below is the transaction,
Expand All @@ -134,29 +160,38 @@ def take(self, key: str, holder: str, lease: float | None) -> Taken:
db = self._connect()
try:
db.execute("BEGIN IMMEDIATE")
db.execute("DELETE FROM claims WHERE until IS NOT NULL AND until <= ?", (now,))
row = db.execute("SELECT holder, until FROM claims WHERE key = ?", (key,)).fetchone()
if row is not None and row[0] != holder:
# An expired or released row is free but kept, so its fencing count
# survives; a released row's holder is empty.
row = db.execute("SELECT holder, until, fence FROM claims WHERE key = ?", (key,)).fetchone()
live = row is not None and row[0] != "" and (row[1] is None or row[1] > now)
if live and row[0] != holder:
db.execute("COMMIT")
return Taken(ok=False, holder=row[0], until=row[1])
return Taken(ok=False, holder=row[0], until=row[1], fence=row[2])
fence = row[2] if live else (0 if row is None else row[2]) + 1
until = None if lease is None else now + lease
db.execute(
"INSERT INTO claims (key, holder, until) VALUES (?, ?, ?)"
" ON CONFLICT(key) DO UPDATE SET holder = excluded.holder, until = excluded.until",
(key, holder, until),
"INSERT INTO claims (key, holder, until, fence) VALUES (?, ?, ?, ?)"
" ON CONFLICT(key) DO UPDATE SET holder = excluded.holder,"
" until = excluded.until, fence = excluded.fence",
(key, holder, until, fence),
)
db.execute("COMMIT")
return Taken(ok=True, holder=holder, until=until)
return Taken(ok=True, holder=holder, until=until, fence=fence)
except BaseException:
db.execute("ROLLBACK")
raise
finally:
db.close()

def release(self, key: str, holder: str) -> bool:
now = self._clock()
db = self._connect()
try:
cur = db.execute("DELETE FROM claims WHERE key = ? AND holder = ?", (key, holder))
cur = db.execute(
"UPDATE claims SET holder = '', until = NULL WHERE key = ? AND holder = ?"
" AND (until IS NULL OR until > ?)",
(key, holder, now),
)
return cur.rowcount > 0
finally:
db.close()
Expand All @@ -166,20 +201,28 @@ def holder(self, key: str) -> str | None:
db = self._connect()
try:
row = db.execute(
"SELECT holder FROM claims WHERE key = ? AND (until IS NULL OR until > ?)",
"SELECT holder FROM claims WHERE key = ? AND holder != ''"
" AND (until IS NULL OR until > ?)",
(key, now),
).fetchone()
return None if row is None else row[0]
finally:
db.close()


def scoped(across: str, team: str, tool: str, action: str, value: str) -> str:
def scoped(
across: str, team: str, tool: str, action: str, value: str, workspace: str = ""
) -> str:
"""The store key for one claim: its scope, then `(tool, action, value)`.

`the-team` is namespaced by the team the run belongs to — the agent at the
root of the request — so two teams using the same tool do not refuse each
other. `the-workspace` is one namespace for everything here.
other. `the-workspace` is namespaced by the workspace's id when the host
names one, so two workspaces sharing one store do not refuse each other
either; without one it is one namespace for everything the store holds.
"""
space = f"team:{team}" if across == "the-team" else "workspace"
if across == "the-team":
space = f"team:{team}"
else:
space = f"workspace:{workspace}" if workspace else "workspace"
return "\x1f".join((space, tool, action, value))
13 changes: 11 additions & 2 deletions adapters/python/src/pact_adapters/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ async def _ask(
# (docs/98 WP-7). WHICH actions claim, how far and for how long is authored
# (`same-request-key-across:`, `claim-lasts:`); the record every run of a
# team shares is a store, and the store is the host's.
"claims", "claims_team",
"claims", "claims_team", "claims_workspace",
# The team's shared memory, and who judges what may go in it (docs/98
# WP-8). Which facts are shared, their shapes and their admission rules are
# authored (`shared-with:`, `admitted-when:`, `admitted-by:`); the entries
Expand Down Expand Up @@ -700,6 +700,9 @@ async def run(
#: The team a `the-team` claim belongs to: the agent at the root of the
#: request. `None` means this agent is the root.
claims_team: str | None = None,
#: The workspace a `the-workspace` claim belongs to, when the host shares
#: one store between workspaces. `None`: the store is this workspace's.
claims_workspace: str | None = None,
#: The team's shared memory for this request (docs/98 WP-8). The root run
#: makes one when its agent shares a fact; every member it starts gets the
#: same object, riding the grant as `at_work` does.
Expand Down Expand Up @@ -810,9 +813,12 @@ async def run(
once.store = claims
rested = rests if rests is not None else Rests()
once.team = claims_team or spec.name
once.workspace = claims_workspace or ""
# Who holds a claim this run takes: the run, by name and a fresh id, so a
# renewed lease (the same holder taking again) is this run and nobody else.
once.holder = f"{spec.name}:{uuid.uuid4().hex}"
# A parked run comes back as the same holder, or the claims it took before
# the park would refuse it.
once.holder = (resume.claim_holder if resume is not None else "") or f"{spec.name}:{uuid.uuid4().hex}"
supplied: dict[str, Any] = dict(run_inputs or {})
# A money ceiling handed in for this run (`cost-per-request-from:`, docs/98
# WP-14), through the same door a teammate's share comes through: the
Expand Down Expand Up @@ -3158,6 +3164,7 @@ def _park_state(
parked.used = meter.as_dict(clock())
parked.granted = dict(given)
parked.spent_keys = once.used()
parked.claim_holder = once.holder
if result.reactor is not None:
parked.reactions = result.reactor.state()
if text:
Expand Down Expand Up @@ -3959,6 +3966,7 @@ def delegate_by_running(
claims: "ClaimStore | None" = None,
team: str | None = None,
admit: "Admit | None" = None,
claims_workspace: str | None = None,
) -> Asker:
"""Ask a team member by *running* it — this same harness, one level down.

Expand Down Expand Up @@ -4083,6 +4091,7 @@ async def ask(grant: Grant) -> str:
# WP-7): a member's `the-team` claim is the team's, not its own.
claims=claims,
claims_team=team,
claims_workspace=claims_workspace,
board=getattr(grant, "board", None),
board_facts=tuple(getattr(grant, "board_facts", ()) or ()),
admit=admit,
Expand Down
9 changes: 9 additions & 0 deletions adapters/python/src/pact_adapters/suspension.py
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,12 @@ class Suspension:
#: it. Empty for a record written before this field existed, which resumes
#: with no keys spent — the same honest degradation `used` already has.
spent_keys: tuple[tuple[str, str, str], ...] = ()
#: Who holds the claims this run took in a claim store (`Ledger.holder`).
#: Carried for the same reason as `spent_keys`: a run that came back under a
#: new name could neither renew nor release what it claimed before the
#: park, and its own claims would refuse it. Empty for a record written
#: before this field existed, which resumes under a new name.
claim_holder: str = ""
#: A fingerprint of the calls this run is waiting to make, taken when it
#: parked (docs/98 WP-12, the exact-action binding). Empty: nothing to bind.
request_digest: str = ""
Expand Down Expand Up @@ -737,6 +743,7 @@ def escalate(self, now: float) -> "Suspension":
# `same-request-key:` exists to make and the exact moment it is
# worth most: money moving while a person decides.
spent_keys=tuple(self.spent_keys),
claim_holder=self.claim_holder,
reactions=dict(self.reactions),
)

Expand Down Expand Up @@ -785,6 +792,7 @@ def to_json(self) -> str:
# tuples below, so the record's shape on this side of the
# process boundary is the same one it had on the other.
"spent-keys": [list(k) for k in self.spent_keys],
"claim-holder": self.claim_holder,
"request-digest": self.request_digest,
"reactions": self.reactions,
},
Expand Down Expand Up @@ -834,6 +842,7 @@ def from_json(text: str) -> "Suspension":
spent_keys=tuple(
(str(t), str(a), str(v)) for t, a, v in (d.get("spent-keys") or ())
),
claim_holder=str(d.get("claim-holder") or ""),
request_digest=str(d.get("request-digest") or ""),
reactions=dict(d.get("reactions") or {}),
)
Expand Down
Loading
Loading