diff --git a/config/examples/windows-native-lifecycle.json b/config/examples/windows-native-lifecycle.json index b6716af7..b2ab6a16 100644 --- a/config/examples/windows-native-lifecycle.json +++ b/config/examples/windows-native-lifecycle.json @@ -1,5 +1,6 @@ { "enabled": false, + "target_type": "prepared", "supervisor_root": "D:/Civ13-local/TEST1714-supervisor", "supervisor_sha256": "REPLACE_WITH_REVIEWED_SUPERVISOR_SHA256", "guard_sha256": "REPLACE_WITH_REVIEWED_MASTER_GUARD_SHA256", diff --git a/docs/windows-native-lifecycle.md b/docs/windows-native-lifecycle.md index 5fe59911..89f64ddc 100644 --- a/docs/windows-native-lifecycle.md +++ b/docs/windows-native-lifecycle.md @@ -3,8 +3,10 @@ This adds a bot-side adapter and a versioned supervisor spool contract. It does **not** add a game supervisor consumer, upgrade an installed supervisor, install dependencies, provision credentials, launch a game or remove a halt latch. -The installed Desktop supervisor does not implement this protocol. Keep the -adapter disabled until a separately reviewed compatible consumer exists. +The installed Desktop supervisor does not implement this protocol. This v2 +source contract is incompatible with v1 consumers. Keep the adapter disabled +until a separately reviewed compatible consumer exists and actual local access +and process ownership have been verified. The native bot entry points retain their default rejection. An explicitly enabled `servers..native_lifecycle` object in a portable profile allows `Host` to @@ -45,11 +47,41 @@ Activation requires all of the following: existing resolved paths are readonly. - Supervisor config fields `root`, `repository`, `branch: "master"`, `bind: "127.0.0.1"`, `port: 1714`, the exact `map` and `daemon`, and - `bot_control_protocol: "civilizationbot.native-supervisor.v1"`. + `bot_control_protocol: "civilizationbot.native-supervisor.v2"`. + +The default `target_type: "prepared"` keeps the existing run-directory rule. +The separately proposed `target_type: "canonical"` binds `basedir` and `gitdir` +to the same exact canonical checkout and the supervisor root to its fixed +`data/desktop-supervisor` directory. The pinned supervisor config must explicitly +enable both `canonical_bot_enabled` and `canonical_update_enabled`, with its +`canonical_game_directory` and `config_directory` matching that checkout. Both +target types remain disabled by default. A separately reviewed consumer pins +its canonical backend, and its current spool-writer guard denies activation. +For initial start, the consumer additionally requires an explicit initial-start +flag and the exact existing saved generation; it never invents a fresh save. +Requests do not select or change the target type or path. + +For review only, the canonical selection inside an otherwise complete portable +server entry has this disabled shape; the normal example remains `prepared`: + +```json +{ + "enabled": false, + "target_type": "canonical", + "supervisor_root": "D:/Civ13-example/master/data/desktop-supervisor" +} +``` + +It still needs all source, config, daemon, revision, map, save and binary pins +from the full example. The server's `basedir` and portable `gitdir` must both +name `D:/Civ13-example/master`; placeholders do not authorize activation. Changing a pinned source/config/build requires explicit profile review and bot restart. The adapter never updates a checkout, compiles, selects an old save, substitutes a fresh round or silently relaxes save validation. +Canonical v2 restart retains the pinned binaries; it is not a canonical update. +A separate canonical update first blocks the old capability, and changed +revision, binaries or saved generation require reconciliation and repinning. ## Supervisor consumer contract @@ -66,7 +98,7 @@ publishes `capability.json` atomically with: ```json { - "protocol": "civilizationbot.native-supervisor.v1", + "protocol": "civilizationbot.native-supervisor.v2", "binding_sha256": "64 lowercase hex characters", "heartbeat_unix": 0, "epoch": "32 lowercase hex characters", @@ -89,8 +121,21 @@ not a port label, filename, stale PID or intended command line. The adapter briefly holds a nonblocking exclusive `adapter.lock`, persists `pending.json`, then publishes `request.json` using an exclusive hard link to a flushed temporary file. A filesystem without the required hard-link/fsync behavior fails closed. +After durable writes, the producer rechecks capability identity, freshness and +request expiry immediately before attempting the link. If this check fails, +the pending and temporary records remain for reconciliation. A blocking link or +clock change can still occur after that check; the consumer independently checks +expiry at admission and before the first native effect. The packet contains only protocol, random operation ID, fixed action, -epoch/sequence, binding hash, complete binding, and the observed `before` process. +epoch/sequence, binding hash, complete binding, the observed `before` process, +and integer `issued_unix` and `expires_unix` seconds. The lifetime is five +minutes; this is an admission bound, not a deadline for completing a save or +restart. The consumer checks age before acceptance and again at the first +native effect, allowing at most two seconds future clock skew. Once an effect +starts, expiration must not interrupt its save/restart transaction. Both sides +require a sufficiently accurate, non-rolled-back system clock; an authorized +spool writer can forge timestamps. A delayed verification may expire a request +and leave accepted evidence requiring reconciliation. An existing request/pending record or consumed epoch/sequence refuses submission. The publication lock is released before waiting; completion reacquires it only for its short verification/acknowledgement transition. A separate process can @@ -115,7 +160,7 @@ On accepting an operation the consumer must: reconcile durable pending/result/state rather than replay a request. Completion results must echo protocol, operation, action, epoch, sequence, -binding hash and exact `before`; set `outcome: "complete"`, +binding hash, exact `before`, `issued_unix` and `expires_unix`; set `outcome: "complete"`, `master_verified: true`, `ready` (false for save-stop, true otherwise), and `after` (null only for save-stop). A running `after` uses the process schema above. Restart must establish a different creation identity. Save-stop/restart @@ -135,6 +180,10 @@ completion retains the result plus a consumed epoch/sequence marker before clearing only the exact pending record. No automatic retry or recovery clearing is provided; an operator must reconcile evidence before changing those records. Longer game operations can finish independently after the bot timeout. +Before migration, stop submitting v1 requests and reconcile surviving v1 +pending/request/inflight and receipts without replay or deletion. Update and +review both pinned producer and consumer together. V1 records are never +reinterpreted with newly invented timestamps. If save-stop/restart advances the generation, a durable marker blocks subsequent operations using the original binding. The operator must review the newly saved generation, update the explicit profile and supervisor capability binding, and diff --git a/src/Civ13/NativeLifecycle.php b/src/Civ13/NativeLifecycle.php index bb3d1d6b..2a013343 100644 --- a/src/Civ13/NativeLifecycle.php +++ b/src/Civ13/NativeLifecycle.php @@ -11,27 +11,36 @@ /** Fixed requests to an independently owned supervisor. Never executes or kills a process. */ final class NativeLifecycle { - public const PROTOCOL = 'civilizationbot.native-supervisor.v1'; + public const PROTOCOL = 'civilizationbot.native-supervisor.v2'; + public const REQUEST_LIFETIME_SECONDS = 300; private array $binding; private array $pins; private string $directory; private string $bindingHash; - public function __construct(private array $settings, array $server, string $repository, private LoopInterface $loop, private $clock = null) + public function __construct(private array $settings, array $server, string $repository, private LoopInterface $loop, private $clock = null, private $wallClock = null) { if (($settings['enabled'] ?? false) !== true) { throw new \LogicException('Native lifecycle adapter is disabled'); } $this->clock ??= static fn () => hrtime(true); + $this->wallClock ??= static fn () => time(); if (($server['enabled'] ?? null) !== true || ($server['ip'] ?? null) !== '127.0.0.1' || (string) ($server['port'] ?? '') !== '1714') { throw new \InvalidArgumentException('Native lifecycle requires loopback TEST1714'); } $root = self::directory($settings['supervisor_root'] ?? ''); $runtime = self::directory($server['basedir'] ?? ''); $repository = self::directory($repository); - if (!str_starts_with($runtime, $root.DIRECTORY_SEPARATOR.'runs'.DIRECTORY_SEPARATOR)) { + $targetType = $settings['target_type'] ?? 'prepared'; + if ($targetType === 'prepared' && !str_starts_with($runtime, $root.DIRECTORY_SEPARATOR.'runs'.DIRECTORY_SEPARATOR)) { throw new \InvalidArgumentException('Explicit supervisor-owned run directory required'); } + if ($targetType === 'canonical' && ($runtime !== $repository || $root !== self::directory($repository.'/data/desktop-supervisor'))) { + throw new \InvalidArgumentException('Explicit canonical target does not match its fixed root'); + } + if (!in_array($targetType, ['prepared', 'canonical'], true)) { + throw new \InvalidArgumentException('Unsupported native target type'); + } $this->directory = self::directory($root.'/bot-control'); $expected = $settings['expected'] ?? []; if (!is_array($expected) || array_keys($expected) !== ['revision', 'map', 'generation', 'dmb_sha256', 'rsc_sha256']) { @@ -61,6 +70,7 @@ public function __construct(private array $settings, array $server, string $repo 'supervisor_sha256' => $settings['supervisor_sha256'], 'guard_sha256' => $settings['guard_sha256'], 'config_sha256' => $settings['config_sha256'], ]; + if ($targetType === 'canonical') { $this->binding['target_type'] = 'canonical'; } $this->bindingHash = hash('sha256', json_encode($this->binding, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); $this->verifyPins(); $config = self::read($root.'/supervisor.json'); @@ -70,6 +80,16 @@ public function __construct(private array $settings, array $server, string $repo || self::file($config['daemon'] ?? '') !== $daemon || ($config['bot_control_protocol'] ?? null) !== self::PROTOCOL) { throw new \InvalidArgumentException('Supervisor does not implement the reviewed loopback native contract'); } + if ($targetType === 'canonical' && (($config['canonical_bot_enabled'] ?? false) !== true + || ($config['canonical_update_enabled'] ?? false) !== true + || self::directory($config['canonical_game_directory'] ?? '') !== $repository + || self::directory($config['config_directory'] ?? '') !== self::directory($repository.'/config'))) { + throw new \InvalidArgumentException('Explicit canonical bot target is disabled or mismatched'); + } + if ($targetType === 'prepared' && (($config['canonical_game_directory'] ?? null) !== null + || ($config['canonical_bot_enabled'] ?? false) === true)) { + throw new \InvalidArgumentException('Canonical configuration cannot use the prepared backend'); + } } /** Public protocol description for the separately reviewed supervisor; contains no credentials. */ @@ -90,9 +110,10 @@ public function inspect(): array throw new \RuntimeException('Saved generation advanced; reconcile the explicit profile before another operation'); } $capability = self::read($this->directory.'/capability.json'); + $now = $this->wallTime(); if (($capability['protocol'] ?? null) !== self::PROTOCOL || ($capability['binding_sha256'] ?? null) !== $this->bindingHash - || !is_int($capability['heartbeat_unix'] ?? null) || $capability['heartbeat_unix'] < time() - 10 - || $capability['heartbeat_unix'] > time() + 2 + || !is_int($capability['heartbeat_unix'] ?? null) || $capability['heartbeat_unix'] < $now - 10 + || $capability['heartbeat_unix'] > $now + 2 || !is_int($capability['sequence'] ?? null) || $capability['sequence'] < 0 || !is_string($capability['epoch'] ?? null) || !preg_match('/^[a-f0-9]{32}$/D', $capability['epoch']) || ($capability['master_verified'] ?? null) !== true || ($capability['busy'] ?? null) !== false) { @@ -129,12 +150,28 @@ public function request(string $action): PromiseInterface // Recheck after serialization, before creating any request. $current = $this->inspect(); if ($current['epoch'] !== $capability['epoch'] || $current['sequence'] !== $capability['sequence'] || $current['process'] !== $capability['process']) { throw new \RuntimeException('Supervisor epoch or owned process changed'); } + $issued = $this->wallTime(); + if ($current['heartbeat_unix'] < $issued - 10 || $current['heartbeat_unix'] > $issued + 2) { + throw new \RuntimeException('Supervisor capability expired before publication'); + } $packet = ['protocol' => self::PROTOCOL, 'operation' => $operation, 'action' => $action, 'epoch' => $capability['epoch'], 'sequence' => $capability['sequence'], 'binding_sha256' => $this->bindingHash, - 'binding' => $this->binding, 'before' => $capability['process']]; + 'binding' => $this->binding, 'before' => $capability['process'], + 'issued_unix' => $issued, 'expires_unix' => $issued + self::REQUEST_LIFETIME_SECONDS]; $bytes = json_encode($packet, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); self::create($pending, $bytes); self::create($temporary, $bytes); + // Durable writes may block. Recheck as close to publication as + // possible; on failure keep both records for reconciliation. + $current = $this->inspect(); + if ($current['epoch'] !== $capability['epoch'] || $current['sequence'] !== $capability['sequence'] || $current['process'] !== $capability['process']) { + throw new \RuntimeException('Supervisor epoch or owned process changed before publication'); + } + $publicationTime = $this->wallTime(); + if ($current['heartbeat_unix'] < $publicationTime - 10 || $current['heartbeat_unix'] > $publicationTime + 2 + || $issued > $publicationTime + 2 || $publicationTime > $packet['expires_unix']) { + throw new \RuntimeException('Native request expired before publication'); + } // Hard-link publication is exclusive: it cannot replace a concurrent request. if (!link($temporary, $request)) { throw new \RuntimeException('Native request publication uncertain; inspect pending operation'); } unlink($temporary); @@ -198,7 +235,7 @@ private function lock(): mixed private function result(array $result, array $packet): array { - foreach (['protocol', 'operation', 'action', 'epoch', 'sequence', 'binding_sha256', 'before'] as $key) { + foreach (['protocol', 'operation', 'action', 'epoch', 'sequence', 'binding_sha256', 'before', 'issued_unix', 'expires_unix'] as $key) { if (!array_key_exists($key, $result) || $result[$key] !== $packet[$key]) { throw new \UnexpectedValueException('Unbound native supervisor result'); } } if (($result['outcome'] ?? null) !== 'complete' || ($result['master_verified'] ?? null) !== true @@ -225,6 +262,15 @@ private function result(array $result, array $packet): array return $result; } + private function wallTime(): int + { + $now = ($this->wallClock)(); + if (!is_int($now) || $now < 0 || $now > PHP_INT_MAX - self::REQUEST_LIFETIME_SECONDS) { + throw new \RuntimeException('Invalid native request clock'); + } + return $now; + } + private function process(mixed $process): void { if ($process === null) { return; } diff --git a/tests/windows/native-lifecycle.php b/tests/windows/native-lifecycle.php index a7604056..883923af 100644 --- a/tests/windows/native-lifecycle.php +++ b/tests/windows/native-lifecycle.php @@ -69,16 +69,38 @@ public function react($emoticon): React\Promise\PromiseInterface { $this->reacti $server = ['enabled'=>true,'ip'=>'127.0.0.1','port'=>'1714','basedir'=>$runtime]; $loop = new NativeLoop(); $clock = (object)['now'=>0]; - $adapter = new NativeLifecycle($settings, $server, $repository, $loop, static fn ()=>$clock->now); - $capability = ['protocol'=>NativeLifecycle::PROTOCOL, 'binding_sha256'=>$adapter->description()['binding_sha256'], 'heartbeat_unix'=>time(), 'epoch'=>str_repeat('c',32),'sequence'=>1,'master_verified'=>true,'busy'=>false,'process'=>null]; + $wall = (object)['now'=>time()]; + $adapter = new NativeLifecycle($settings, $server, $repository, $loop, static fn ()=>$clock->now, static fn ()=>$wall->now); + $capability = ['protocol'=>NativeLifecycle::PROTOCOL, 'binding_sha256'=>$adapter->description()['binding_sha256'], 'heartbeat_unix'=>$wall->now, 'epoch'=>str_repeat('c',32),'sequence'=>1,'master_verified'=>true,'busy'=>false,'process'=>null]; writeRecord($dir.'/bot-control/capability.json',$capability); $process = ['pid'=>1234, 'creation_filetime'=>'134367709910115498','executable'=>$daemon,'working_directory'=>$runtime,'dmb_sha256'=>$settings['expected']['dmb_sha256'],'rsc_sha256'=>$settings['expected']['rsc_sha256']]; - return compact('dir','runtime','repository','settings','server','loop','clock','adapter','capability','process','config'); + return compact('dir','runtime','repository','settings','server','loop','clock','wall','adapter','capability','process','config'); + }; + $canonicalFixture = static function () use ($root, &$caseNumber): array { + $repository = $root.'/canonical-'.++$caseNumber; + mkdir($repository); mkdir($repository.'/config'); mkdir($repository.'/data'); + mkdir($repository.'/data/desktop-supervisor'); + $repository = realpath($repository); $dir = realpath($repository.'/data/desktop-supervisor'); + mkdir($dir.'/software'); mkdir($dir.'/bot-control'); mkdir($dir.'/runs'); + foreach (['software/supervisor.py','software/master_guard.py','dd.exe'] as $name) { file_put_contents($dir.'/'.$name, 'inert '.$name); } + foreach (['civ13.dmb','civ13.rsc'] as $name) { file_put_contents($repository.'/'.$name, 'inert '.$name); } + $daemon = realpath($dir.'/dd.exe'); + $config = ['root'=>$dir,'repository'=>$repository,'canonical_game_directory'=>$repository, + 'config_directory'=>realpath($repository.'/config'),'canonical_update_enabled'=>true,'canonical_bot_enabled'=>true, + 'branch'=>'master','bind'=>'127.0.0.1','port'=>1714,'map'=>'NOMADS_GAIA','daemon'=>$daemon,'bot_control_protocol'=>NativeLifecycle::PROTOCOL]; + writeRecord($dir.'/supervisor.json',$config); + $settings = ['enabled'=>true,'target_type'=>'canonical','supervisor_root'=>$dir,'daemon'=>$daemon,'daemon_sha256'=>hash_file('sha256',$daemon), + 'supervisor_sha256'=>hash_file('sha256',$dir.'/software/supervisor.py'),'guard_sha256'=>hash_file('sha256',$dir.'/software/master_guard.py'), + 'config_sha256'=>hash_file('sha256',$dir.'/supervisor.json'),'expected'=>['revision'=>str_repeat('a',40),'map'=>'NOMADS_GAIA', + 'generation'=>'round-v2:'.str_repeat('b',32),'dmb_sha256'=>hash_file('sha256',$repository.'/civ13.dmb'),'rsc_sha256'=>hash_file('sha256',$repository.'/civ13.rsc')]]; + $server=['enabled'=>true,'ip'=>'127.0.0.1','port'=>'1714','basedir'=>$repository]; $loop=new NativeLoop(); + return compact('dir','repository','settings','server','loop','config'); }; $complete = static function (array $f, string $action): array { $request = json_decode(file_get_contents($f['dir'].'/bot-control/request.json'),true,16,JSON_THROW_ON_ERROR); check($request['action']===$action,'Wrong fixed action'); - $result = array_intersect_key($request,array_flip(['protocol','operation','action','epoch','sequence','binding_sha256','before'])); + check($request['expires_unix']-$request['issued_unix']===NativeLifecycle::REQUEST_LIFETIME_SECONDS,'Wrong request lifetime'); + $result = array_intersect_key($request,array_flip(['protocol','operation','action','epoch','sequence','binding_sha256','before','issued_unix','expires_unix'])); $after = $action==='save-stop' ? null : $f['process']; if ($action==='restart') { $after['pid']=2345; $after['creation_filetime']='134367709910115499'; } $result += ['outcome'=>'complete','master_verified'=>true,'ready'=>$action!=='save-stop','after'=>$after]; @@ -95,6 +117,19 @@ public function react($emoticon): React\Promise\PromiseInterface { $this->reacti $f['loop']->tick(); }; try { + $f=$canonicalFixture(); $adapter=new NativeLifecycle($f['settings'],$f['server'],$f['repository'],$f['loop']); + check(($adapter->description()['binding']['target_type']??null)==='canonical','Canonical type is not explicitly bound'); ++$cases; + foreach (['canonical_bot_enabled','canonical_update_enabled'] as $field) { + $f=$canonicalFixture();$config=$f['config'];$config[$field]=false;writeRecord($f['dir'].'/supervisor.json',$config); + $settings=$f['settings'];$settings['config_sha256']=hash_file('sha256',$f['dir'].'/supervisor.json'); + refuses(fn()=>new NativeLifecycle($settings,$f['server'],$f['repository'],$f['loop'])); ++$cases; + } + $f=$canonicalFixture();$settings=$f['settings'];unset($settings['target_type']); + refuses(fn()=>new NativeLifecycle($settings,$f['server'],$f['repository'],$f['loop'])); ++$cases; + $settings['target_type']='arbitrary';refuses(fn()=>new NativeLifecycle($settings,$f['server'],$f['repository'],$f['loop'])); ++$cases; + $f=$fixture();$config=$f['config'];$config['canonical_game_directory']=$f['repository'];writeRecord($f['dir'].'/supervisor.json',$config); + $settings=$f['settings'];$settings['config_sha256']=hash_file('sha256',$f['dir'].'/supervisor.json'); + refuses(fn()=>new NativeLifecycle($settings,$f['server'],$f['repository'],$f['loop'])); ++$cases; // Default rejection does not need a valid supervisor, credentials or dependencies. $f=$fixture(); $disabled=$f['settings'];$disabled['enabled']=false; refuses(fn()=>new NativeLifecycle($disabled,$f['server'],$f['repository'],$f['loop'])); ++$cases; @@ -113,7 +148,7 @@ public function react($emoticon): React\Promise\PromiseInterface { $this->reacti } $f=$fixture();file_put_contents($f['dir'].'/HALTED.json','preserve');refuses(fn()=>$f['adapter']->request('start'));check(file_get_contents($f['dir'].'/HALTED.json')==='preserve','Halt latch changed');++$cases; $f=$fixture();unlink($f['dir'].'/bot-control/capability.json');refuses(fn()=>$f['adapter']->request('start'));++$cases; - $f=$fixture();refuses(fn()=>$f['adapter']->request('mapswap'));refuses(fn()=>$f['adapter']->request('start & arbitrary'));refuses(fn()=>$f['adapter']->request('save-stop'));++$cases; + $f=$fixture();refuses(fn()=>$f['adapter']->request('mapswap'));refuses(fn()=>$f['adapter']->request('update'));refuses(fn()=>$f['adapter']->request('start & arbitrary'));refuses(fn()=>$f['adapter']->request('save-stop'));++$cases; foreach (['start','save-stop','restart'] as $action) { $f=$fixture();if($action!=='start'){$cap=$f['capability'];$cap['process']=$f['process'];writeRecord($f['dir'].'/bot-control/capability.json',$cap);} @@ -136,7 +171,7 @@ public function react($emoticon): React\Promise\PromiseInterface { $this->reacti check($failure===null&&$success['restored_generation']===$updated['expected']['generation'],'Reconciled start did not require newly saved generation');++$cases; } } - foreach (['operation'=>str_repeat('0',32),'epoch'=>str_repeat('0',32),'binding_sha256'=>str_repeat('0',64),'outcome'=>'unknown','master_verified'=>false,'restored_generation'=>'round-v2:'.str_repeat('0',32),'ready'=>false] as $field=>$bad) { + foreach (['operation'=>str_repeat('0',32),'epoch'=>str_repeat('0',32),'binding_sha256'=>str_repeat('0',64),'issued_unix'=>0,'expires_unix'=>0,'outcome'=>'unknown','master_verified'=>false,'restored_generation'=>'round-v2:'.str_repeat('0',32),'ready'=>false] as $field=>$bad) { $f=$fixture();$promise=$f['adapter']->request('start');$result=$complete($f,'start');$original=$result['operation'];$result[$field]=$bad; unlink($f['dir'].'/bot-control/request.json');writeRecord($f['dir'].'/bot-control/result-'.$original.'.json',$result);$success=null;$failure=null;$await($promise,$f,$success,$failure); check($success===null&&$failure!==null,'Unbound/unknown result was accepted');check(is_file($f['dir'].'/bot-control/pending.json'),'Unknown outcome lost persistent latch');refuses(fn()=>$f['adapter']->request('start'));++$cases; @@ -151,6 +186,44 @@ public function react($emoticon): React\Promise\PromiseInterface { $this->reacti $f=$fixture();$cap=$f['capability'];$cap['process']=$f['process'];writeRecord($f['dir'].'/bot-control/capability.json',$cap);$promise=$f['adapter']->request('restart');$result=$complete($f,'restart');$result['after']=$f['process'];$publishResult($f,$result);$success=null;$failure=null;$await($promise,$f,$success,$failure);check($failure!==null,'Same-process restart accepted');++$cases; $f=$fixture();$promise=$f['adapter']->request('start');$result=$complete($f,'start');$publishResult($f,$result,false);$success=null;$failure=null;$await($promise,$f,$success,$failure);check($failure!==null,'Unconsumed request accepted');++$cases; $f=$fixture();$promise=$f['adapter']->request('start');$f['clock']->now=601*1000000000;$success=null;$failure=null;$await($promise,$f,$success,$failure);check($failure!==null&&is_file($f['dir'].'/bot-control/pending.json')&&is_file($f['dir'].'/bot-control/request.json'),'Timeout retried, killed, or lost evidence');++$cases; + // This synthetic matching receipt checks only the producer's response policy; + // it does not establish when a real consumer admitted the operation. + $f=$fixture();$promise=$f['adapter']->request('start');$result=$complete($f,'start');$f['wall']->now+=301;$publishResult($f,$result);$success=null;$failure=null;$await($promise,$f,$success,$failure);check($failure===null&&$success===$result,'Matching late receipt rejected as expired');++$cases; + foreach ([-1, 'invalid', PHP_INT_MAX] as $badClock) { + $f=$fixture();$f['wall']->now=$badClock;refuses(fn()=>$f['adapter']->request('start')); + check(!file_exists($f['dir'].'/bot-control/request.json'),'Invalid wall clock published request');++$cases; + } + $f=$fixture();$reads=0;$issued=$f['wall']->now; + $delayed=new NativeLifecycle($f['settings'],$f['server'],$f['repository'],$f['loop'],null, + static function()use(&$reads,$issued){return $issued+(++$reads>2?11:0);}); + refuses(fn()=>$delayed->request('start')); + check(!file_exists($f['dir'].'/bot-control/pending.json'),'Expired capability published a packet');++$cases; + // The clock advances only after both durable records are written, + // modeling delayed file IO before the exclusive hard link. + $f=$fixture();$reads=0;$issued=$f['wall']->now; + $delayed=new NativeLifecycle($f['settings'],$f['server'],$f['repository'],$f['loop'],null, + static function()use(&$reads,$issued){return $issued+(++$reads>3?11:0);}); + refuses(fn()=>$delayed->request('start')); + check(is_file($f['dir'].'/bot-control/pending.json')&&!file_exists($f['dir'].'/bot-control/request.json')&&count(glob($f['dir'].'/bot-control/request-*.tmp'))===1,'Delayed publication lost evidence or linked stale request');++$cases; + // Even if the capability refreshed during IO, the original request's + // own timestamp must still be checked immediately before publication. + $f=$fixture();$reads=0;$issued=$f['wall']->now;$cap=$f['capability']; + $delayed=new NativeLifecycle($f['settings'],$f['server'],$f['repository'],$f['loop'],null, + static function()use(&$reads,$issued,$cap,$f){ + ++$reads; + if($reads===3){$cap['heartbeat_unix']=$issued+301;writeRecord($f['dir'].'/bot-control/capability.json',$cap);} + return $issued+($reads>3?301:0); + }); + refuses(fn()=>$delayed->request('start')); + check(is_file($f['dir'].'/bot-control/pending.json')&&!file_exists($f['dir'].'/bot-control/request.json')&&count(glob($f['dir'].'/bot-control/request-*.tmp'))===1,'Expired request was published or lost evidence');++$cases; + $f=$fixture();$reads=0;$issued=$f['wall']->now;$cap=$f['capability']; + $delayed=new NativeLifecycle($f['settings'],$f['server'],$f['repository'],$f['loop'],null, + static function()use(&$reads,$issued,$cap,$f){ + if(++$reads===3){$cap['epoch']=str_repeat('f',32);writeRecord($f['dir'].'/bot-control/capability.json',$cap);} + return $issued; + }); + refuses(fn()=>$delayed->request('start')); + check(is_file($f['dir'].'/bot-control/pending.json')&&!file_exists($f['dir'].'/bot-control/request.json'),'Changed capability was published or lost evidence');++$cases; $f=$fixture();$promise=$f['adapter']->request('start');$result=$complete($f,'start');$publishResult($f,$result);file_put_contents($f['dir'].'/runs/owned/civ13.dmb','changed after request');$success=null;$failure=null;$await($promise,$f,$success,$failure);check($failure!==null,'Build replacement during operation accepted');++$cases; $f=$fixture();$promise=$f['adapter']->request('start');$request=json_decode(file_get_contents($f['dir'].'/bot-control/request.json'),true);unlink($f['dir'].'/bot-control/request.json');file_put_contents($f['dir'].'/bot-control/result-'.$request['operation'].'.json','malformed');$success=null;$failure=null;$await($promise,$f,$success,$failure);check($failure!==null,'Malformed result accepted');++$cases; $f=$fixture();file_put_contents($f['dir'].'/bot-control/pending.json','interrupted-before-publication');refuses(fn()=>$f['adapter']->request('start'));check(!file_exists($f['dir'].'/bot-control/request.json'),'Interrupted publication was replayed');++$cases;