fix: make cross-role allocations consumable by independent workers (Closes #843)
Controller-created role=author allocations were owned by the allocating controller session with no authorized consume path for independent author workers. When the controller exited, the lease became stale_dead_process and required abandon/reassign instead of a usable handoff. - Mark cross-role apply with durable handoff provenance (pending) - Allow gitea_adopt_workflow_lease to consume pending handoffs by the required role without sharing controller session identity or requiring the controller process to remain alive - Atomically transfer assignment+lease ownership and set adopted_by_session_id with read-after-write evidence - Reject wrong-role, second, and terminal adoptions - Surface consume_allocation identifiers in process_work_queue results - Preserve same-role allocation and genuine abandon recovery behavior Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+169
-3
@@ -1637,11 +1637,13 @@ class ControlPlaneDB:
|
||||
provenance: dict[str, Any] | None = None,
|
||||
lease_ttl_seconds: int = DEFAULT_LEASE_TTL_SECONDS,
|
||||
) -> dict[str, Any]:
|
||||
"""Transfer or refresh a lease with provenance (#601).
|
||||
"""Transfer or refresh a lease with provenance (#601 / #843).
|
||||
|
||||
* Same owner + active → refresh (owner-resume).
|
||||
* Cross-role handoff pending + matching required role → atomic consume
|
||||
(even while the allocating controller session still "owns" the lease).
|
||||
* Expired/abandoned/released → create new assignment+lease with provenance.
|
||||
* Active foreign → raise ForeignLeaseError (never silent steal).
|
||||
* Active foreign (non-handoff) → raise ForeignLeaseError (never silent steal).
|
||||
"""
|
||||
now = _utc_now()
|
||||
now_s = _ts(now)
|
||||
@@ -1677,7 +1679,35 @@ class ControlPlaneDB:
|
||||
status = "expired"
|
||||
|
||||
owner = lease["session_id"]
|
||||
if status == "active" and owner != adopter_session_id:
|
||||
# Parse durable provenance for cross-role handoff consume (#843).
|
||||
lease_prov: dict[str, Any] = {}
|
||||
if "provenance_json" in lease.keys() and lease["provenance_json"]:
|
||||
try:
|
||||
loaded = json.loads(lease["provenance_json"])
|
||||
if isinstance(loaded, dict):
|
||||
lease_prov = loaded
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
lease_prov = {}
|
||||
handoff_pending = bool(lease_prov.get("cross_role_handoff")) and (
|
||||
str(lease_prov.get("handoff_status") or "pending").strip().lower()
|
||||
== "pending"
|
||||
)
|
||||
already_adopted = bool(
|
||||
(lease["adopted_by_session_id"] if "adopted_by_session_id" in lease.keys() else None)
|
||||
or lease_prov.get("adopted_by_session_id")
|
||||
)
|
||||
required_role = str(
|
||||
lease_prov.get("required_role") or lease["role"] or ""
|
||||
).strip().lower()
|
||||
adopter_role = (role or "").strip().lower()
|
||||
cross_role_consume = (
|
||||
handoff_pending
|
||||
and not already_adopted
|
||||
and status == "active"
|
||||
and owner != adopter_session_id
|
||||
)
|
||||
|
||||
if status == "active" and owner != adopter_session_id and not cross_role_consume:
|
||||
raise ForeignLeaseError(
|
||||
f"cannot adopt active foreign lease {lease_id} owned by {owner}"
|
||||
)
|
||||
@@ -1761,6 +1791,142 @@ class ControlPlaneDB:
|
||||
"reasons": ["owner-resume: refreshed lease with provenance"],
|
||||
}
|
||||
|
||||
# #843: controller→required-role handoff consume (atomic, same lease_id)
|
||||
if cross_role_consume:
|
||||
if not required_role:
|
||||
raise ControlPlaneError(
|
||||
f"cross-role handoff lease {lease_id} missing required_role"
|
||||
)
|
||||
if adopter_role != required_role:
|
||||
raise ForeignLeaseError(
|
||||
f"wrong role for cross-role handoff consume: "
|
||||
f"required={required_role} adopter={adopter_role or 'none'} "
|
||||
f"(fail closed)"
|
||||
)
|
||||
# CAS: only transfer if still owned by allocating session and unadopted
|
||||
cols = self._lease_columns(conn)
|
||||
adopted_col_null = (
|
||||
"(adopted_by_session_id IS NULL OR adopted_by_session_id = '')"
|
||||
if "adopted_by_session_id" in cols
|
||||
else "1=1"
|
||||
)
|
||||
cas = conn.execute(
|
||||
f"""
|
||||
UPDATE leases
|
||||
SET session_id = ?,
|
||||
heartbeat_at = ?,
|
||||
expires_at = ?,
|
||||
phase = ?,
|
||||
role = ?
|
||||
WHERE lease_id = ?
|
||||
AND status = 'active'
|
||||
AND session_id = ?
|
||||
AND {adopted_col_null}
|
||||
""",
|
||||
(
|
||||
adopter_session_id,
|
||||
now_s,
|
||||
expires,
|
||||
"adopted",
|
||||
required_role,
|
||||
lease_id,
|
||||
owner,
|
||||
),
|
||||
)
|
||||
if cas.rowcount != 1:
|
||||
raise ForeignLeaseError(
|
||||
f"cross-role handoff consume lost race for lease {lease_id} "
|
||||
"(already adopted or no longer pending; fail closed)"
|
||||
)
|
||||
if "adopted_from_session_id" in cols:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE leases
|
||||
SET adopted_from_session_id = ?, adopted_by_session_id = ?
|
||||
WHERE lease_id = ?
|
||||
""",
|
||||
(owner, adopter_session_id, lease_id),
|
||||
)
|
||||
if "worktree_path" in cols and worktree_path:
|
||||
conn.execute(
|
||||
"UPDATE leases SET worktree_path = ? WHERE lease_id = ?",
|
||||
(worktree_path, lease_id),
|
||||
)
|
||||
if "owner_pid" in cols and owner_pid is not None:
|
||||
conn.execute(
|
||||
"UPDATE leases SET owner_pid = ? WHERE lease_id = ?",
|
||||
(owner_pid, lease_id),
|
||||
)
|
||||
if "expected_head_sha" in cols and expected_head_sha:
|
||||
conn.execute(
|
||||
"UPDATE leases SET expected_head_sha = ? WHERE lease_id = ?",
|
||||
(expected_head_sha, lease_id),
|
||||
)
|
||||
# Merge handoff provenance + caller provenance
|
||||
merged = dict(lease_prov)
|
||||
merged.update(provenance or {})
|
||||
merged["cross_role_handoff"] = True
|
||||
merged["handoff_status"] = "adopted"
|
||||
merged["adopted_from_session_id"] = owner
|
||||
merged["adopted_by_session_id"] = adopter_session_id
|
||||
merged["required_role"] = required_role
|
||||
if "provenance_json" in cols:
|
||||
conn.execute(
|
||||
"UPDATE leases SET provenance_json = ? WHERE lease_id = ?",
|
||||
(json.dumps(merged), lease_id),
|
||||
)
|
||||
# Transfer active assignment ownership atomically
|
||||
asn_cas = conn.execute(
|
||||
"""
|
||||
UPDATE assignments
|
||||
SET session_id = ?, role = ?
|
||||
WHERE lease_id = ? AND status = 'active' AND session_id = ?
|
||||
""",
|
||||
(adopter_session_id, required_role, lease_id, owner),
|
||||
)
|
||||
if asn_cas.rowcount < 1:
|
||||
# Fail closed: assignment must move with the lease
|
||||
raise ControlPlaneError(
|
||||
f"cross-role handoff: no active assignment for lease {lease_id} "
|
||||
f"owned by {owner}"
|
||||
)
|
||||
lease2 = conn.execute(
|
||||
"SELECT * FROM leases WHERE lease_id = ?", (lease_id,)
|
||||
).fetchone()
|
||||
asn = conn.execute(
|
||||
"""
|
||||
SELECT * FROM assignments
|
||||
WHERE lease_id = ? AND status = 'active'
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""",
|
||||
(lease_id,),
|
||||
).fetchone()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO events(work_item_id, event_type, message, created_at)
|
||||
VALUES (?, 'lease_adopted', ?, ?)
|
||||
""",
|
||||
(
|
||||
lease["work_item_id"],
|
||||
f"cross-role handoff: {adopter_session_id} consumed "
|
||||
f"{lease_id} from {owner} as {required_role}",
|
||||
now_s,
|
||||
),
|
||||
)
|
||||
return {
|
||||
"outcome": "adopted_cross_role_handoff",
|
||||
"lease": dict(lease2) if lease2 else dict(lease),
|
||||
"assignment": dict(asn) if asn else None,
|
||||
"reasons": [
|
||||
"cross-role handoff: independent required-role worker consumed "
|
||||
"controller allocation without abandonment"
|
||||
],
|
||||
"adopted_by_session_id": adopter_session_id,
|
||||
"adopted_from_session_id": owner,
|
||||
"required_role": required_role,
|
||||
"handoff_status": "adopted",
|
||||
}
|
||||
|
||||
# Non-active: create new lease + assignment (transfer)
|
||||
new_lease_id = f"lease-{uuid.uuid4().hex[:16]}"
|
||||
new_asn_id = f"asn-{uuid.uuid4().hex[:16]}"
|
||||
|
||||
Reference in New Issue
Block a user