181 lines
16 KiB
PHP
181 lines
16 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace app\adminapi\service {
|
|
function hrtime(bool $number = false) { $now = $GLOBALS['r26Clock']; return $number ? $now : [intdiv($now, 1000000000), $now % 1000000000]; }
|
|
function usleep(int $us): void { $GLOBALS['r26Clock'] += $us * 1000; }
|
|
}
|
|
|
|
namespace {
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
use app\adminapi\service\AssistantRequestControl as Control;
|
|
$GLOBALS['r26Clock'] = 100000000000;
|
|
$checks = 0; $number = 0;
|
|
$expect = static function (bool $ok, string $message) use (&$checks): void { ++$checks; if (!$ok) throw new \RuntimeException($message); };
|
|
$dir = sys_get_temp_dir() . '/r26-generation-' . bin2hex(random_bytes(8));
|
|
$make = static function (string $run = 'fixture-run') use ($dir, &$number, $expect): array {
|
|
$GLOBALS['r26Clock'] += 10000000000;
|
|
$uuid = sprintf('b2345678-1234-4234-8234-%012d', ++$number);
|
|
$control = new Control(7, 11, $uuid, $dir);
|
|
$expect($control->begin(), 'Fresh synthetic request begins.');
|
|
$task = 'fixture-task-' . $number;
|
|
$control->bindTask('qwen', 'fixture-user', $task, $run);
|
|
return [$control, $task, $dir . '/' . hash('sha256', '7:11:' . $uuid) . '.json', $uuid];
|
|
};
|
|
$read = static fn (string $path): array => json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
|
|
$write = static function (string $path, array $state): void { file_put_contents($path, json_encode($state, JSON_THROW_ON_ERROR)); };
|
|
$reply = static fn (array $identity, string $status): array => ['ok' => true, 'workflow_run_id' => $identity['workflow_run_id'], 'status' => $status];
|
|
try {
|
|
[$c, $task, $path, $uuid] = $make();
|
|
$c->markStop(true, $task); $c->markTerminal($task, 'stopped', 'fixture-run');
|
|
$p = $c->publicState();
|
|
$expect($p['producer_terminal_received'] && !$p['upstream_terminal_confirmed'] && $p['upstream_pending_task_count'] === 1, 'Stop acceptance / SSE receipt is not independent cleanup.');
|
|
$expect(!$c->canStartAttempt() && isset($read($path)['upstream']), 'SSE stopped retains identity and blocks successor.');
|
|
$calls = 0;
|
|
$running = static function (array $id, int $ms) use (&$calls, $c, $path, $read, $expect, $reply): array {
|
|
++$calls; $expect($ms > 0 && $ms <= 1000, 'Original one-second I/O cap.');
|
|
$expect(!$c->canStartAttempt() && $read($path)['transfer_closed'], 'Own generation is closed before lookup; gate remains closed during I/O.');
|
|
$peer = new Control(7, 11, $GLOBALS['r26CurrentUuid'], dirname($path));
|
|
$nested = $peer->reconcileTerminal($id['task_id'], static function (): array { throw new \RuntimeException('Peer must not duplicate claim'); });
|
|
$expect(in_array($nested['reason'], ['verification_in_progress', 'attempts_exhausted'], true), 'Cross-worker claim survives outside unlocked I/O.');
|
|
return $reply($id, 'running');
|
|
};
|
|
$GLOBALS['r26CurrentUuid'] = $uuid;
|
|
$closed = $c->closeGeneration($task, $running);
|
|
$state = $read($path); $p = $c->publicState();
|
|
$expect(!$closed['confirmed'] && $closed['pending'] && $calls === 2, 'At most two original-pair lookups, never a model request.');
|
|
$expect($p['persisted_run_status'] === 'running' && $p['producer_terminal_status'] === 'stopped' && $p['lifecycle_conflict'], 'Contrary statuses both retained without override.');
|
|
$expect($p['upstream_pending_task_count'] === 1 && !$c->canStartAttempt(), 'Contradiction blocks compatibility/continuation handoff.');
|
|
$expect(count($state['run_status_observations']) === 2 && $state['verification_attempts'] === 2, 'Bounded private observation history.');
|
|
$started = $state['verification_started_at'];
|
|
$c->closeGeneration($task, $running); $c->finish('error'); $c->reconcileClosedTasks($running);
|
|
$expect($calls === 2 && $read($path)['verification_started_at'] === $started, 'Repeated close/final recovery never renews attempts or budget.');
|
|
$state = $read($path); $state['expires_at'] = time() - 1000; $write($path, $state); touch($path, time() - 1000);
|
|
$peer = new Control(7, 11, $uuid, $dir);
|
|
$expect(is_file($path) && $peer->publicState()['upstream_pending_task_count'] === 1 && !$peer->begin(), 'TTL/GC retains contradiction and original identity.');
|
|
foreach (['producer_terminal_observed_at','persisted_run_observed_at','stop_accepted_at','generation_transfer_closed_at'] as $key)
|
|
$expect(is_float($p[$key]) && $p[$key] > 0, 'Distinct observation time: ' . $key);
|
|
$expect(!str_contains(json_encode($p), $task) && !str_contains(json_encode($p), 'fixture-run') && !str_contains(json_encode($p), 'fixture-user'), 'Public receipts never reveal original identities.');
|
|
|
|
foreach (['stopped','succeeded','failed'] as $status) {
|
|
[$c,$task,$path] = $make(); $c->markTerminal($task,$status,'fixture-run'); $calls=0;
|
|
$r=$c->reconcileTerminal($task,static function () use (&$calls): array {++$calls;return [];});
|
|
$expect($r['reason']==='transport_open' && $calls===0, 'No lookup from a live transport/SSE callback.');
|
|
$r=$c->closeGeneration($task,static function(array $id,int $ms)use($reply,$status,&$calls):array{++$calls;return $reply($id,$status);});
|
|
$state=$read($path);
|
|
$expect($r['confirmed'] && $calls===1 && $c->canStartAttempt(), 'Independent terminal agreement permits successor.');
|
|
$expect(!isset($state['upstream']) && count($state['completed_generations'])===1, 'Original pair and receipts archived privately after completion.');
|
|
$archive=array_values($state['completed_generations'])[0];
|
|
$expect($archive['upstream']['task_id']===$task && $archive['producer_terminal_status']===$status && $archive['persisted_run_status']===$status, 'Archive retains exact generation/pair and separate status sources.');
|
|
$c->closeGeneration($task,static function():array{throw new \RuntimeException('No repeated completed lookup');});
|
|
try {$c->bindTask('qwen','fixture-user',$task,'fixture-run');$expect(false,'Completed identity must not get a new budget');}
|
|
catch(\RuntimeException $e){$expect($e->getMessage()==='UPSTREAM_TASK_IDENTITY_CONFLICT','Completed identity cannot be replayed as a fresh generation.');}
|
|
$c->bindTask('qwen','fixture-user','next-'.$task,'next-run-'.$task);
|
|
$expect(!$c->canStartAttempt() && count($read($path)['completed_generations'])===1, 'New generation gets own pending state and preserves prior receipts.');
|
|
}
|
|
|
|
foreach (['foreign_run','foreign_task','foreign_user','foreign_profile','missing_ok','false_ok','string_ok','bad_status'] as $bad) {
|
|
[$c,$task,$path]=$make(); $c->markTerminal($task,'stopped','fixture-run');
|
|
$c->closeGeneration($task,static function(array $id)use($bad,$reply):array{
|
|
$r=$reply($id,'succeeded');
|
|
if($bad==='foreign_run')$r['workflow_run_id']='foreign-run';
|
|
if($bad==='foreign_task')$r['task_id']='foreign-task';
|
|
if($bad==='foreign_user')$r['user']='foreign-user';
|
|
if($bad==='foreign_profile')$r['profile']='openai';
|
|
if($bad==='missing_ok')unset($r['ok']);
|
|
if($bad==='false_ok')$r['ok']=false;
|
|
if($bad==='string_ok')$r['ok']='true';
|
|
if($bad==='bad_status')$r['status']='finished';
|
|
return $r;
|
|
});
|
|
$expect(!$c->publicState()['upstream_terminal_confirmed'] && isset($read($path)['upstream']), 'Malformed/foreign lookup cannot erase identity: '.$bad);
|
|
$expect(!isset($read($path)['persisted_run_status']), 'Unverified alias/status is never a recorded run observation.');
|
|
}
|
|
|
|
foreach (['scheduled','running','partial-succeeded','paused'] as $status) {
|
|
[$c,$task,$path]=$make();$c->markTerminal($task,'stopped','fixture-run');$calls=0;
|
|
$c->closeGeneration($task,static function(array $id)use($status,$reply,&$calls):array{++$calls;return $reply($id,$status);});
|
|
$p=$c->publicState();
|
|
$expect($calls===2 && $p['persisted_run_status']===$status && !$p['upstream_terminal_confirmed'] && !$c->canStartAttempt(), 'Nonterminal allowlist is observational only: '.$status);
|
|
$stopCalls=0;$c->requestCancel(static function()use(&$stopCalls):bool{++$stopCalls;return true;});
|
|
$expect($stopCalls===0 && $c->publicState()['cancel_requested'], 'Producer receipt avoids redundant stop I/O without certifying cleanup.');
|
|
}
|
|
|
|
[$c,$task,$path]=$make();$c->markTerminal($task,'stopped','fixture-run');$calls=0;
|
|
$r=$c->closeGeneration($task,static function(array $id)use(&$calls,$reply):array{return $reply($id,++$calls===1?'running':'succeeded');});
|
|
$state=$read($path);$archive=array_values($state['completed_generations'])[0];
|
|
$expect($r['confirmed'] && $c->canStartAttempt() && $c->publicState()['lifecycle_conflict'], 'Later exact independent terminal proves ended while retaining disagreement history.');
|
|
$expect(array_column($archive['run_status_observations'],'status')===['running','succeeded'] && $archive['producer_terminal_status']==='stopped', 'Both exact run observations and original producer receipt survive archive.');
|
|
|
|
foreach(['generation','task_id','workflow_run_id','profile','user','token']as$changed){
|
|
[$c,$task,$path]=$make();$c->markTerminal($task,'stopped','fixture-run');
|
|
$r=$c->closeGeneration($task,static function(array $id)use($changed,$path,$read,$write,$reply):array{
|
|
$state=$read($path);
|
|
if($changed==='generation')++$state['upstream']['generation'];
|
|
elseif($changed==='token')$state['verification_claim']['token']='replacement';
|
|
else $state['upstream'][$changed]='replacement';
|
|
$write($path,$state);return $reply($id,'succeeded');
|
|
});
|
|
$expect($r['reason']==='stale_result' && !isset($read($path)['persisted_run_status']) && !$c->canStartAttempt(), 'Changed ownership/claim cannot receive an old verification result: '.$changed);
|
|
}
|
|
|
|
[$c,$task,$path]=$make();
|
|
$c->markTerminal('foreign-task','stopped','fixture-run'); $c->markTerminal($task,'stopped','wrong-run');
|
|
$expect(!$c->publicState()['producer_terminal_received'], 'Foreign producer receipts cannot establish status.');
|
|
$c->markTerminal($task,'stopped','fixture-run');
|
|
$c->closeGeneration($task,static function(array $id)use($reply):array{$GLOBALS['r26Clock']+=1100000000;return $reply($id,'succeeded');});
|
|
$expect(!$c->publicState()['upstream_terminal_confirmed'] && isset($read($path)['upstream']), 'Late response never confirms cleanup.');
|
|
|
|
[$c,$task,$path]=$make(); $c->markTerminal($task,'stopped','fixture-run');
|
|
$r=$c->closeGeneration($task,static function(array $id)use($path,$read,$write,$reply):array{
|
|
$state=$read($path);$state['upstream']['generation']++;$write($path,$state);return $reply($id,'succeeded');
|
|
});
|
|
$expect($r['reason']==='stale_result' && !isset($read($path)['persisted_run_status']), 'Generation race cannot apply stale same-run result.');
|
|
|
|
[$c,$task,$path]=$make(''); $c->markTerminal($task,'stopped'); $calls=0;
|
|
$r=$c->closeGeneration($task,static function()use(&$calls):array{++$calls;return [];});
|
|
$expect($r['reason']==='identity_unavailable' && $calls===0 && $c->publicState()['upstream_pending_task_count']===1, 'Legacy task-only terminal remains unconfirmed without guessed run.');
|
|
|
|
[$c,$task,$path]=$make();
|
|
$legacy=$read($path);$legacy['upstream_terminal_confirmed']=true;$legacy['upstream_terminal_status']='stopped';$write($path,$legacy);
|
|
$c->clearTask($task);
|
|
$expect(isset($read($path)['upstream']) && !$c->publicState()['upstream_terminal_confirmed'], 'Old SSE-only flag cannot migrate into independent proof.');
|
|
|
|
[$c,$task,$path]=$make();
|
|
try{$c->bindTask('qwen','fixture-user','handoff-task','handoff-run');$expect(false,'Unexpected created task blocks');}catch(\RuntimeException $e){$expect($e->getMessage()==='UPSTREAM_TASK_HANDOFF_BLOCKED','Created handoff retained.');}
|
|
$c->markTerminal('handoff-task','stopped','handoff-run'); $c->clearTask('handoff-task');
|
|
$expect($c->publicState()['upstream_pending_task_count']===2,'Handoff producer receipt cannot clear either generation.');
|
|
$c->closeGeneration('handoff-task',static fn(array $id):array=>$reply($id,'stopped'));
|
|
$expect($c->publicState()['upstream_pending_task_count']===1 && isset($read($path)['upstream']), 'Handoff verification only releases its own exact pair.');
|
|
$c->signalCancel(); $c->signalTimeout(); $c->finish('error');
|
|
$expect($c->publicState()['cancel_requested'] && $c->publicState()['timeout_reached'] && $c->publicState()['status']==='cancelled', 'Original cancel/timeout intent survives lifecycle verification.');
|
|
|
|
[$c,$task,$path]=$make();
|
|
foreach(['second','third']as$suffix){try{$c->bindTask('qwen','fixture-user','handoff-'.$suffix,'run-'.$suffix);}catch(\RuntimeException $e){$expect($e->getMessage()==='UPSTREAM_TASK_HANDOFF_BLOCKED','Recovery retains another own original pair.');}}
|
|
$c->finish('error');$calls=0;$timeouts=[];
|
|
$r=$c->reconcileClosedTasks(static function(array $id,int $ms)use(&$calls,&$timeouts,$reply):array{
|
|
++$calls;$timeouts[]=$ms;
|
|
$GLOBALS['r26Clock']+=($calls<=2?700:$ms)*1000000;
|
|
return $reply($id,$calls<=2?'running':'succeeded');
|
|
});
|
|
$expect($calls===3 && $r['elapsed_ms']===2000 && $r['status']==='budget_exhausted' && $r['upstream_pending_task_count']===3,'Whole-request recovery retains original global two-second cap across all targets.');
|
|
$expect($timeouts[0]===1000 && $timeouts[1]===1000 && $timeouts[2]>0 && $timeouts[2]<=350,'Later target receives only original remaining I/O budget.');
|
|
$globalStart=$read($path)['closed_verification_started_at'];$GLOBALS['r26Clock']+=10000000000;
|
|
$r=$c->reconcileClosedTasks(static function()use(&$calls):array{++$calls;return [];});
|
|
$expect($calls===3 && $r['budget_exhausted'] && $read($path)['closed_verification_started_at']===$globalStart,'Repeated final recovery cannot renew its global budget.');
|
|
|
|
[$c,$task,$path]=$make();$state=$read($path);$state['verification_started_at']=$GLOBALS['r26Clock']/1e9-1.8;$state['verification_attempts']=1;$write($path,$state);
|
|
$r=$c->closeGeneration($task,static function(array $id,int $ms)use($expect,$reply):array{$expect($ms>0&&$ms<=200,'Per-generation close narrows an existing budget.');return $reply($id,'stopped');});
|
|
$expect($r['confirmed'] && $read($path)['verification_attempts']===2,'Existing generation verification budget is reused at EOF.');
|
|
|
|
[$c,$task,$path]=$make();$state=$read($path);$state['verification_started_at']=$GLOBALS['r26Clock']/1e9+1;$write($path,$state);$calls=0;
|
|
$r=$c->closeGeneration($task,static function()use(&$calls):array{++$calls;return [];});
|
|
$expect($calls===0 && $r['reason']==='budget_exhausted' && !$c->canStartAttempt(),'Monotonic-clock reset never renews or bypasses a generation budget.');
|
|
echo "R26 per-generation lifecycle: {$checks} assertions passed (synthetic controls/clock, no network/bootstrap/model).\n";
|
|
} finally {
|
|
$real=realpath($dir);$temp=realpath(sys_get_temp_dir());
|
|
if($real!==false){if($temp===false||!str_starts_with(str_replace('\\','/',$real),rtrim(str_replace('\\','/',$temp),'/').'/r26-generation-'))throw new \RuntimeException('Cleanup boundary');
|
|
foreach(new \DirectoryIterator($real)as$file){if($file->isDot())continue;if($file->isLink()||!$file->isFile())throw new \RuntimeException('Unexpected temp entry');unlink($file->getPathname());}rmdir($real);}
|
|
}
|
|
}
|