Dispatch explicit bindspace lifecycles

Add serialized `BindspaceSpec.lifecycle` policy and dispatch it through
`open_bindspace()` without inferring ownership from transport role.

Deats,
- distinguish borrowed `attach` from owned `open` policy
- validate handle ownership against the declared lifecycle
- share policy-neutral FD pinning between both netns contexts
- reject unsupported lifecycle values before side effects
- exercise both dispatcher branches and owned cancellation cleanup

Prompt-IO: ai/prompt-io/opencode/20260825T191845Z_5b2a064a_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
Gud Boi 2026-08-25 19:33:12 -04:00
parent a625a2757d
commit 02f1e9f72c
6 changed files with 194 additions and 31 deletions

View File

@ -0,0 +1,40 @@
---
model: gpt-5.6-sol
service: opencode
session: tractor-addr-unpacking
timestamp: 2026-08-25T19:18:45Z
git_ref: 5b2a064a
scope: code
substantive: true
raw_file: 20260825T191845Z_5b2a064a_prompt_io.raw.md
---
## Prompt
The human pointed out that the agent had produced no changes after the
previous commit and expected the next incremental implementation.
## Response summary
Corrected the sequencing mistake and added explicit serialized
bindspace lifecycle policy plus generic `open_bindspace()` dispatch.
Borrowed and owned netns contexts share a policy-neutral FD-pinning
primitive while validating their public lifecycle before side effects.
## Files changed
- `tractor/discovery/_bindspace.py` - lifecycle policy, ownership
invariants, shared pinning and dispatch.
- `tractor/discovery/__init__.py` - public policy and dispatcher exports.
- `tests/discovery/test_bindspace.py` - lifecycle serialization,
validation and both dispatcher branches.
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - explicit lifecycle
policy independent of transport role.
## Human edits
The human identified that the agent had accidentally repeated a
summary of already committed work instead of implementing the next
slice. That correction directly caused this lifecycle/dispatcher
change to be implemented. The agent wrote the source changes; no
direct manual edits were observed.

View File

@ -0,0 +1,30 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-25T19:18:45Z
git_ref: 5b2a064a
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - dispatch explicit bindspace lifecycle
The human noticed the agent had re-reported already committed work
without changing the worktree. The agent confirmed the sequencing
error and proceeded to the actual next Layer C slice.
> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
Added serialized `BindspaceSpec.lifecycle` policy with explicit
`attach` and `open` choices. Added `open_bindspace()` dispatch without
inferring resource ownership from listen/dial role.
Refactored FD attachment into policy-neutral `_pin_netns()` so
borrowed and owned public lifecycles can share identity pinning while
enforcing their own policy before side effects. Handle construction
also verifies lifecycle and resulting ownership agree.
One composition error introduced during implementation was caught by
the focused test: owned creation initially called the newly guarded
public attach API. `_pin_netns()` fixed that layering error. Ruff and
lock checks passed; discovery plus message coverage passed 138 tests
with 2 xpasses.

View File

@ -408,6 +408,7 @@ class BindspaceSpec(msgspec.Struct, frozen=True):
'''Serializable spawn/config declaration.'''
kind: str # `netns`, later `vrf`, ...
key: str|None # requested name/key, if any
lifecycle: Literal['attach', 'open']
class BindspaceIdentity(msgspec.Struct, frozen=True):
@ -428,8 +429,6 @@ class BindspaceHandle(ProcessLocal):
@acm
async def open_bindspace(
spec: BindspaceSpec,
*,
role: Literal['listen', 'dial'],
) -> AsyncGenerator[BindspaceHandle, None]:
'''
Provision/borrow one bindspace and yield its live capability.
@ -451,6 +450,12 @@ survives rename/unlink, and identifies the exact namespace the parent
provisioned. Extend the kind/field union only when a second platform
resource is implemented.
`BindspaceSpec.lifecycle` is explicit serialized policy:
`'attach'` borrows an existing resource and `'open'` creates/owns one.
`open_bindspace()` dispatches that policy by bindspace kind. Never
infer it from a listen/dial role: either role may use pre-provisioned
or locally owned networking.
The first lifecycle implementation is deliberately borrow-only:
`attach_netns()` opens either `/proc/self/ns/net` when
`BindspaceSpec.key = CURRENT_NETNS`, or a named entry beneath
@ -474,7 +479,6 @@ use the handle to replace an overlay while preserving every tunnel:
```python
async with open_bindspace(
bindspace_spec,
role='listen',
) as bindspace:
listen_decl = declared_addr.get_random(
bindspace=bindspace,
@ -499,7 +503,6 @@ tunnel/bindspace layer:
@acm
async def open_netns(
spec: BindspaceSpec,
role: Literal['listen', 'dial'],
) -> AsyncGenerator[BindspaceHandle, None]: ...
@acm
@ -519,9 +522,10 @@ it already returns
`dict[str, list[Address]]|dict[...]` return for tunnelled
entries. Extend it to carry the tunnel stack, not to *enter* it.
The caller supplies `role`; do not infer it from maddr shape. The same
composed maddr can name a server source or client destination, and the
required local provisioning/ownership differs (§5.4).
The caller supplies `role` to tunnel-resource contexts such as
`open_wg_iface()`; do not infer it from maddr shape. Bindspace
lifecycle remains the independent explicit policy above. The same
composed maddr can name a server source or client destination (§5.4).
### 5.3 `Address.namespace`, at last

View File

@ -20,6 +20,7 @@ from tractor.discovery import (
BindspaceSpec,
CURRENT_NETNS,
attach_netns,
open_bindspace,
open_netns,
)
from tractor.discovery import _bindspace
@ -39,7 +40,11 @@ def test_bindspace_declarations_roundtrip() -> None:
BindspaceSpec|BindspaceIdentity,
...,
] = (
BindspaceSpec(kind='netns', key='tractor-wg0'),
BindspaceSpec(
kind='netns',
key='tractor-wg0',
lifecycle='open',
),
BindspaceIdentity(
kind='netns',
key='tractor-wg0',
@ -79,6 +84,7 @@ def test_bindspace_handle_pins_local_capability(
spec: BindspaceSpec = BindspaceSpec(
kind='netns',
key='tractor-wg0',
lifecycle='open',
)
identity: BindspaceIdentity = BindspaceIdentity(
kind='netns',
@ -207,6 +213,16 @@ def test_bindspace_handle_rejects_mismatched_identity(
'BindspaceSpec.key',
id='spec-rejects-empty-key',
),
pytest.param(
BindspaceSpec,
{
'kind': 'netns',
'key': 'tractor-wg0',
'lifecycle': 'replace',
},
'Unsupported bindspace lifecycle',
id='spec-rejects-lifecycle',
),
pytest.param(
BindspaceIdentity,
{
@ -240,7 +256,7 @@ def test_bindspace_models_reject_invalid_values(
sys.platform != 'linux',
reason='Linux netns API',
)
def test_attach_current_netns_borrows_and_closes() -> None:
def test_open_bindspace_attaches_current_netns() -> None:
'''
The unnamed spec must borrow and pin the caller's current netns.
@ -259,7 +275,7 @@ def test_attach_current_netns_borrows_and_closes() -> None:
kind='netns',
)
assert spec.key is CURRENT_NETNS
async with attach_netns(spec) as handle:
async with open_bindspace(spec) as handle:
namespace_fd: int|None = handle.namespace_fd
assert namespace_fd is not None
assert handle.spec is spec
@ -426,8 +442,9 @@ def test_open_netns_owns_lifecycle(
spec: BindspaceSpec = BindspaceSpec(
kind='netns',
key='tractor-wg0',
lifecycle='open',
)
async with open_netns(spec) as handle:
async with open_bindspace(spec) as handle:
fd: int|None = handle.namespace_fd
assert fd is not None
assert handle.ownership == 'owned'
@ -503,6 +520,7 @@ def test_open_netns_shields_cancelled_cleanup(
spec: BindspaceSpec = BindspaceSpec(
kind='netns',
key='tractor-wg0',
lifecycle='open',
)
with trio.CancelScope() as scope:
async with open_netns(spec):
@ -529,6 +547,7 @@ def test_open_netns_requires_name() -> None:
spec: BindspaceSpec = BindspaceSpec(
kind='netns',
key=CURRENT_NETNS,
lifecycle='open',
)
async def main() -> None:

View File

@ -28,10 +28,12 @@ from ._bindspace import (
BindspaceHandle as BindspaceHandle,
BindspaceIdentity as BindspaceIdentity,
BindspaceKind as BindspaceKind,
BindspaceLifecycle as BindspaceLifecycle,
BindspaceOwnership as BindspaceOwnership,
BindspaceSpec as BindspaceSpec,
CURRENT_NETNS as CURRENT_NETNS,
attach_netns as attach_netns,
open_bindspace as open_bindspace,
open_netns as open_netns,
)
from ._multiaddr import (

View File

@ -41,6 +41,10 @@ from ..msg._local import ProcessLocal
BindspaceKind: TypeAlias = Literal[
'netns',
]
BindspaceLifecycle: TypeAlias = Literal[
'attach', # borrow one existing platform resource
'open', # create, own and remove one platform resource
]
BindspaceOwnership: TypeAlias = Literal[
'owned', # manager tears down the resource after final release
'borrowed', # manager leaves the pre-existing resource intact
@ -65,6 +69,19 @@ def _validate_bindspace_kind(
)
def _validate_bindspace_lifecycle(
lifecycle: BindspaceLifecycle,
) -> None:
'''
Reject lifecycle policies without an implementation.
'''
if lifecycle not in get_args(BindspaceLifecycle):
raise ValueError(
f'Unsupported bindspace lifecycle: {lifecycle!r}'
)
def _validate_bindspace_key(
kind: BindspaceKind,
key: str|None,
@ -112,6 +129,7 @@ class BindspaceSpec(
'''
kind: BindspaceKind
key: str|None = CURRENT_NETNS
lifecycle: BindspaceLifecycle = 'attach'
def __post_init__(self) -> None:
'''
@ -119,6 +137,7 @@ class BindspaceSpec(
'''
_validate_bindspace_kind(self.kind)
_validate_bindspace_lifecycle(self.lifecycle)
_validate_bindspace_key(
self.kind,
self.key,
@ -207,6 +226,16 @@ class BindspaceHandle(
raise ValueError(
f'Invalid bindspace ownership: {ownership!r}'
)
expected_ownership: BindspaceOwnership = (
'borrowed'
if spec.lifecycle == 'attach'
else 'owned'
)
if ownership != expected_ownership:
raise ValueError(
f'`BindspaceSpec.lifecycle={spec.lifecycle!r}` '
f'requires ownership={expected_ownership!r}!'
)
if namespace_fd is not None:
if (
type(namespace_fd) is not int
@ -237,23 +266,14 @@ class BindspaceHandle(
@acm
async def attach_netns(
async def _pin_netns(
spec: BindspaceSpec,
ownership: BindspaceOwnership,
) -> AsyncIterator[BindspaceHandle]:
'''
Borrow and pin one existing Linux network namespace.
`BindspaceSpec.key = CURRENT_NETNS` selects the calling process's
current netns. A named key resolves beneath the standard iproute2
netns run directory. "Attach" pins an existing namespace FD; this
context never calls `setns()` or creates/removes a namespace.
Pin one existing Linux network namespace with explicit ownership.
'''
if sys.platform != 'linux':
raise NotImplementedError(
'Network namespace bindspaces are Linux-only!'
)
key: str|None = spec.key
namespace_path: Path = (
_SELF_NETNS
@ -275,13 +295,41 @@ async def attach_netns(
spec=spec,
identity=identity,
namespace_fd=namespace_fd,
ownership='borrowed',
ownership=ownership,
)
yield handle
finally:
os.close(namespace_fd)
@acm
async def attach_netns(
spec: BindspaceSpec,
) -> AsyncIterator[BindspaceHandle]:
'''
Borrow and pin one existing Linux network namespace.
`BindspaceSpec.key = CURRENT_NETNS` selects the calling process's
current netns. A named key resolves beneath the standard iproute2
netns run directory. "Attach" pins an existing namespace FD; this
context never calls `setns()` or creates/removes a namespace.
'''
if sys.platform != 'linux':
raise NotImplementedError(
'Network namespace bindspaces are Linux-only!'
)
if spec.lifecycle != 'attach':
raise ValueError(
'`attach_netns()` requires lifecycle=`attach`!'
)
async with _pin_netns(
spec,
ownership='borrowed',
) as handle:
yield handle
def _create_netns(
key: str,
) -> None:
@ -332,6 +380,10 @@ async def open_netns(
raise NotImplementedError(
'Network namespace bindspaces are Linux-only!'
)
if spec.lifecycle != 'open':
raise ValueError(
'`open_netns()` requires lifecycle=`open`!'
)
key: str|None = spec.key
if key is CURRENT_NETNS:
@ -349,13 +401,10 @@ async def open_netns(
)
created = True
async with attach_netns(spec) as borrowed:
handle: BindspaceHandle = BindspaceHandle(
spec=spec,
identity=borrowed.identity,
namespace_fd=borrowed.namespace_fd,
ownership='owned',
)
async with _pin_netns(
spec,
ownership='owned',
) as handle:
yield handle
finally:
if created:
@ -365,3 +414,22 @@ async def open_netns(
key,
abandon_on_cancel=False,
)
@acm
async def open_bindspace(
spec: BindspaceSpec,
) -> AsyncIterator[BindspaceHandle]:
'''
Dispatch one declared bindspace lifecycle.
Lifecycle is explicit serialized policy. It is never inferred
from whether the eventual transport role is listen or dial.
'''
if spec.lifecycle == 'attach':
async with attach_netns(spec) as handle:
yield handle
else:
async with open_netns(spec) as handle:
yield handle