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
8 changes: 8 additions & 0 deletions docs/reference/local-delegation.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,11 @@ five minutes total. Execution and resume keep their original one-shot CLI path,
including the existing leased Host supervisor's current-execution readback,
renewal and nested-process cleanup. Lease-bearing commands never enter the
preview worker; preview reuse grants no lease or execution authority.
The leased supervisor keeps current-execution checks at `min(30 s, remaining/2)`.
It renews only when the remaining lifetime is at most half the requested TTL,
then requires an advanced version and current readback. Healthy long-lived
leases therefore avoid a write at each proof check. Owner/key/epoch checks,
the last proved expiry, exact-intent retry and final proof still apply.
This is an internal transport change, not a new capability setting, execution
grant or UI source of truth. A one-shot CLI inspection still pays its original cold startup;
a warm-service measurement is not evidence of a faster cold CLI.
Expand Down Expand Up @@ -508,6 +513,9 @@ best-effort 进程树边界。
总寿命 5 分钟;执行和恢复沿用一次性 CLI,包括既有租约 Host 的当前执行读回、
续期和嵌套进程清理。带租约的命令不进入预检 worker,预检复用不授予租约或执行
权限。这不增加配置、授权或 UI 权威。
租约监督器仍按 `min(30 秒, 剩余寿命/2)` 检查当前执行,只在剩余寿命不超过
请求 TTL 的一半时续租,并要求版本前进及当前读回。因此长租约的普通证明检查
不再每次写入;owner/key/epoch、最后已证明的到期时间、原意图重试及末次证明不变。
一次性 CLI 仍承担原来的冷启动,热服务的加速不能冒充冷 CLI 的加速。

首次验收读取或预检后的末次复读遇到权限存储不可用时,均返回同一类型化不可用
Expand Down
29 changes: 18 additions & 11 deletions loopx/control_plane/turn_driver/leased_host_process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,20 +107,27 @@ export async function runLeasedHostProcess(request: HostProcessRequest, context:
if (finished) return;
renewalTimer = setTimeout(() => {
pending = (async () => {
const argv = [...context.renew_argv, "--expected-version", String(leaseVersion(lease)),
"--ttl-seconds", String(context.ttl_seconds)];
try {
let renewed: unknown;
try { renewed = await cli(argv); }
catch (error) {
if (!(error instanceof LeaseTransportUnavailable) || controller.signal.aborted) throw error;
// One bounded same-intent recovery for a lost response. Never
// adopt a newer version by editing the rejected renewal request.
renewed = await cli(argv);
// Keep the existing revocation/proof cadence, but do not rewrite a
// long-lived lease on every observation. Renew in its latter half;
// this leaves the same retry/deadline supervision for short leases.
const deadline = parseIsoTimestamp(String(lease.expires_at));
const renew = deadline === null || deadline.valueOf() - Date.now() <= context.ttl_seconds * 500;
if (renew) {
const argv = [...context.renew_argv, "--expected-version", String(leaseVersion(lease)),
"--ttl-seconds", String(context.ttl_seconds)];
let renewed: unknown;
try { renewed = await cli(argv); }
catch (error) {
if (!(error instanceof LeaseTransportUnavailable) || controller.signal.aborted) throw error;
// One bounded same-intent recovery for a lost response. Never
// adopt a newer version by editing the rejected renewal request.
renewed = await cli(argv);
}
if (requireJsonObject(renewed, "delegated renewal").ok !== true) throw new LeaseSupervisionFailure("renewal_rejected");
}
if (requireJsonObject(renewed, "delegated renewal").ok !== true) throw new LeaseSupervisionFailure("renewal_rejected");
const observed = currentProof(await cli(context.read_argv));
if (leaseVersion(observed) <= leaseVersion(lease)) throw new LeaseSupervisionFailure("renewal_not_advanced");
if (renew && leaseVersion(observed) <= leaseVersion(lease)) throw new LeaseSupervisionFailure("renewal_not_advanced");
lease = observed;
schedule();
} catch (error) { reject(error, "renewal"); }
Expand Down
48 changes: 48 additions & 0 deletions tests/control_plane/test_leased_host_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,54 @@ def command(*arguments):
capture_output=True, text=True, timeout=30, check=True)


@pytest.mark.skipif(os.name == "nt", reason="POSIX owned process-group qualification")
@pytest.mark.parametrize("revoke", [False, True])
def test_long_ttl_checks_authority_without_rewriting_lease(canonical_execution, tmp_path, revoke):
context, command, selected = canonical_execution
command("task-lease", "renew", *selected, "--owner", "worker", "--idempotency-key",
"original-execution", "--expected-version", "1", "--ttl-seconds", "2700")
context["lease"] = command("task-lease", "inspect", *selected)["lease"]
context["ttl_seconds"] = 2700
version = context["lease"]["version"]
trace = tmp_path / "commands.jsonl"
relay = """import json,subprocess,sys
from pathlib import Path
with Path(sys.argv[1]).open('a') as stream:
stream.write(json.dumps({'phase':sys.argv[2]})+'\\n')
result=subprocess.run(sys.argv[3:],capture_output=True,text=True)
print(result.stdout,end='')
sys.exit(result.returncode)
"""
for field, phase in (("renew_argv", "renew"), ("read_argv", "read")):
context[field] = [sys.executable, "-c", relay, str(trace), phase, *context[field]]
released = []

def ready(_text):
if revoke and not released:
released.append(command("task-lease", "release", *selected, "--owner", "worker",
"--idempotency-key", "original-execution", "--expected-version", str(version)))

started = time.monotonic()
observed = run_host_process([sys.executable, "-c",
"import time;print('ready',flush=True);time.sleep(33);print('finished')"],
project=tmp_path, input_text="", timeout_seconds=50, delegated_lease=context, on_stdout=ready)
phases = [json.loads(line)["phase"] for line in trace.read_text().splitlines()]
current = command("task-lease", "inspect", *selected)["lease"]
assert "renew" not in phases, "periodic proof must not rewrite a healthy long-lived lease"
assert phases.count("read") >= (2 if revoke else 3), phases
assert current["lease_epoch"] == context["lease"]["lease_epoch"]
assert current["idempotency_key"] == context["lease"]["idempotency_key"]
if revoke:
assert released[0]["ok"] is True
assert observed["outcome"] == "cancelled" and observed["output_complete"] is False
assert observed["lease_failure"] == {"reason": "lease_inactive", "boundary": "renewal"}
assert time.monotonic() - started < 45, "revocation must retain the existing proof cadence"
else:
assert observed["outcome"] == "exited" and observed["output_complete"] is True
assert "lease_failure" not in observed
assert current["version"] == version


@pytest.mark.skipif(os.name == "nt", reason="POSIX owned process-group qualification")
@pytest.mark.parametrize("fault", ["rejected", "hung", "lost_reply", "late_reply"])
def test_real_renewal_faults_keep_original_deadline_and_identity(canonical_execution, tmp_path, fault, record_property):
Expand Down
Loading