195 lines
16 KiB
PHP
195 lines
16 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
// Independent deterministic Control tests. Every state directory is synthetic;
|
|
// no framework, original runtime, credentials, configuration or provider I/O.
|
|
namespace app\adminapi\service {
|
|
function hrtime(bool $number = false) {
|
|
$n = $GLOBALS['r26iNs'];
|
|
return $number ? $n : [intdiv($n, 1000000000), $n % 1000000000];
|
|
}
|
|
function usleep(int $microseconds): void { $GLOBALS['r26iNs'] += $microseconds * 1000; }
|
|
}
|
|
namespace {
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
use app\adminapi\service\AssistantRequestControl as Control;
|
|
$GLOBALS['r26iNs'] = 100000000000;
|
|
$checks = 0; $failures = []; $number = 0; $observations = [];
|
|
$expect = static function (bool $ok, string $label) use (&$checks, &$failures): void {
|
|
++$checks; if (!$ok) $failures[] = $label;
|
|
};
|
|
$root = dirname(__DIR__, 2); $artifact = $root . '/app/artifacts/ai_concurrency/';
|
|
$pins = [
|
|
'server/app/adminapi/service/AssistantRequestControl.php' => 'b28c536bae6d33f4a63d62db3a1010cafa17edb1294d7b201415aef37a37855b',
|
|
'server/app/common/service/DifyChatService.php' => '98ebee60d92df7dc61a882fc210514126284fd889d22d1d441708db1627ed10e',
|
|
'server/app/adminapi/controller/tcm/DiagnosisController.php' => 'ef724dba6a04a3d2c041a009fa2386976d3d33759647b1ec4726cff3af36ed8e',
|
|
'server/app/common/service/DifyWorkflowRunStatus.php' => 'eeb1a690d308f4c9ed38000ba76077167a7aeac83356d9d89bd137c5600375ec',
|
|
];
|
|
foreach ($pins as $path => $sha) $expect(hash_file('sha256', $root . '/' . $path) === $sha, 'exact reviewed source pin before ' . $path);
|
|
$directory = sys_get_temp_dir() . '/r26-independent-lifecycle-' . bin2hex(random_bytes(8));
|
|
$read = static fn (string $path): array => json_decode(file_get_contents($path), true, 64, JSON_THROW_ON_ERROR);
|
|
$write = static fn (string $path, array $value): int|false => file_put_contents($path, json_encode($value, JSON_THROW_ON_ERROR));
|
|
$make = static function (string $run = 'private-independent-run') use (&$number, $directory, $expect): array {
|
|
$GLOBALS['r26iNs'] += 10000000000;
|
|
$uuid = sprintf('f3456789-1234-4234-8234-%012d', ++$number);
|
|
$task = 'private-independent-task-' . $number;
|
|
$c = new Control(17, 11, $uuid, $directory);
|
|
$expect($c->begin(), 'fresh isolated request begins');
|
|
$c->bindTask('qwen', 'private-independent-user', $task, $run);
|
|
return [$c, $task, $directory . '/' . hash('sha256', '17:11:' . $uuid) . '.json', $uuid];
|
|
};
|
|
$reply = static fn (array $id, string $status): array => ['ok' => true, 'workflow_run_id' => $id['workflow_run_id'], 'status' => $status];
|
|
$privacy = static function (Control $c) use ($expect): void {
|
|
$p = $c->publicState(); $encoded = json_encode($p, JSON_THROW_ON_ERROR);
|
|
foreach (['private-independent-', 'f3456789-', 'raw-provider-secret'] as $sentinel)
|
|
$expect(!str_contains($encoded, $sentinel), 'safe state excludes private identity/body/exception sentinel');
|
|
foreach (['producer_terminal_observed_at', 'persisted_run_observed_at', 'stop_accepted_at', 'generation_transfer_closed_at'] as $key)
|
|
$expect($p[$key] === null || ((is_float($p[$key]) || is_int($p[$key])) && is_finite((float) $p[$key]) && $p[$key] >= 0), 'safe observation time is finite or null ' . $key);
|
|
};
|
|
$unlocked = static function (string $path) use ($directory, $expect): void {
|
|
foreach ([$directory . '/.gc.lock', $path] as $file) {
|
|
$lock = fopen($file, 'c+'); $held = flock($lock, LOCK_EX | LOCK_NB);
|
|
$expect($held, 'neither GC nor request file lock held across injected I/O');
|
|
if ($held) flock($lock, LOCK_UN); fclose($lock);
|
|
}
|
|
};
|
|
try {
|
|
foreach (['stopped', 'succeeded', 'failed'] as $producer) foreach (['stopped', 'succeeded', 'failed'] as $persisted) {
|
|
[$c, $task, $path] = $make(); $c->markTerminal($task, $producer, 'private-independent-run');
|
|
$expect(!$c->canStartAttempt() && !$c->publicState()['upstream_terminal_confirmed'], 'producer enum alone never releases gate');
|
|
$calls = 0;
|
|
$r = $c->closeGeneration($task, static function (array $id, int $ms) use (&$calls, $reply, $persisted, $unlocked, $path, $expect): array {
|
|
++$calls; $unlocked($path); $expect($ms > 0 && $ms <= 1000, 'per-generation I/O timeout remains bounded');
|
|
return $reply($id, $persisted);
|
|
});
|
|
$s = $read($path); $a = array_values($s['completed_generations'] ?? [])[0] ?? [];
|
|
$expect($r['confirmed'] && $calls === 1 && $c->canStartAttempt(), 'exact independently terminal enum permits eligible handoff');
|
|
$expect(($a['producer_terminal_status'] ?? null) === $producer && ($a['persisted_run_status'] ?? null) === $persisted,
|
|
'archive keeps both terminal sources even when enums differ');
|
|
$expect(($a['run_status_observations'][0]['status'] ?? null) === $persisted
|
|
&& ($a['run_status_observations'][0]['within_budget'] ?? false) === true, 'private archive retains exact within-budget status history');
|
|
$expect($c->publicState()['lifecycle_conflict'] === ($producer !== $persisted), 'terminal disagreement reported without terminal-state override');
|
|
$expect(($a['upstream']['task_id'] ?? null) === $task && ($a['upstream']['workflow_run_id'] ?? null) === 'private-independent-run'
|
|
&& !isset($s['upstream']), 'completed pair retained privately while active binding is released');
|
|
$privacy($c);
|
|
}
|
|
|
|
// Strict boolean true is intentional. All other adapter shapes remain
|
|
// negative, even with an exact run and terminal enum.
|
|
foreach (['missing', null, false, 0, 1, '', 'true', [], (object) ['value' => true]] as $numberOk => $ok) {
|
|
[$c, $task, $path] = $make(); $c->markTerminal($task, 'stopped', 'private-independent-run'); $calls = 0;
|
|
$r = $c->closeGeneration($task, static function (array $id) use ($ok, &$calls, $reply): array {
|
|
++$calls; $value = $reply($id, 'succeeded');
|
|
if ($ok === 'missing') unset($value['ok']); else $value['ok'] = $ok;
|
|
return $value;
|
|
});
|
|
$s = $read($path);
|
|
$expect(!$r['confirmed'] && $r['pending'] && $calls === 2 && !$c->canStartAttempt(), 'non-true or missing ok fails closed ' . $numberOk);
|
|
$expect(!isset($s['persisted_run_status']) && !isset($s['completed_generations']) && isset($s['upstream']), 'invalid ok creates no trusted observation/archive ' . $numberOk);
|
|
$privacy($c);
|
|
}
|
|
|
|
[$c, $task, $path, $uuid] = $make(); $c->signalValidationFailure(); $stopCalls = 0;
|
|
$c->stopBoundTask(static function (array $id) use ($path, $unlocked, &$stopCalls): bool { ++$stopCalls; $unlocked($path); return true; });
|
|
$c->markTerminal($task, 'stopped', 'private-independent-run'); $calls = 0;
|
|
$running = static function (array $id) use (&$calls, $path, $unlocked, $c, $uuid, $directory, $expect, $reply): array {
|
|
++$calls; $unlocked($path);
|
|
$peer = new Control(17, 11, $uuid, $directory); $duplicate = 0;
|
|
$r = $peer->reconcileTerminal($id['task_id'], static function () use (&$duplicate): array { ++$duplicate; return []; });
|
|
$expect($duplicate === 0 && in_array($r['reason'], ['verification_in_progress', 'attempts_exhausted'], true), 'shared claim prevents cross-worker duplicate I/O');
|
|
$expect(!$c->canStartAttempt(), 'handoff remains blocked during status I/O');
|
|
return $reply($id, 'running');
|
|
};
|
|
$c->closeGeneration($task, $running); $s = $read($path); $started = $s['verification_started_at'];
|
|
$expect($calls === 2 && $stopCalls === 1 && $c->publicState()['upstream_pending_task_count'] === 1
|
|
&& $c->publicState()['persisted_run_status'] === 'running' && $c->publicState()['lifecycle_conflict'], 'actual stopped/running shape is pending and blocks successor');
|
|
$c->closeGeneration($task, $running); $c->finish('error'); $c->reconcileClosedTasks($running);
|
|
$expect($calls === 2 && $read($path)['verification_started_at'] === $started && count($read($path)['run_status_observations']) === 2,
|
|
'EOF and final recovery share original attempts/start with bounded history');
|
|
$s = $read($path); $s['expires_at'] = time() - 1200; $write($path, $s); touch($path, time() - 1200);
|
|
$peer = new Control(17, 11, $uuid, $directory);
|
|
$expect(is_file($path) && $peer->publicState()['upstream_pending_task_count'] === 1 && !$peer->begin(), 'TTL GC does not erase unresolved producer receipt or restart UUID');
|
|
$privacy($peer);
|
|
|
|
// Previous running is retained after a later differing terminal. A new
|
|
// generation cannot reuse a completed task or run to renew its budget.
|
|
[$c, $task, $path] = $make(); $c->markTerminal($task, 'stopped', 'private-independent-run'); $calls = 0;
|
|
$c->closeGeneration($task, static function (array $id) use (&$calls, $reply): array { return $reply($id, ++$calls === 1 ? 'running' : 'failed'); });
|
|
$a = array_values($read($path)['completed_generations'])[0];
|
|
$expect(array_column($a['run_status_observations'], 'status') === ['running', 'failed'] && $a['producer_terminal_status'] === 'stopped', 'running and differing terminal histories both survive archive');
|
|
foreach ([[$task, 'private-independent-new-run'], ['private-independent-new-task', 'private-independent-run']] as [$replayTask, $replayRun]) {
|
|
$thrown = false; try { $c->bindTask('qwen', 'private-independent-user', $replayTask, $replayRun); }
|
|
catch (RuntimeException $e) { $thrown = $e->getMessage() === 'UPSTREAM_TASK_IDENTITY_CONFLICT'; }
|
|
$expect($thrown && count($read($path)['completed_generations']) === 1, 'completed task or run cannot be replayed for a fresh budget');
|
|
}
|
|
$c->bindTask('qwen', 'private-independent-user', 'private-independent-next', 'private-independent-next-run');
|
|
$c->markTerminal($task, 'succeeded', 'private-independent-run'); $c->markStop(true, $task); $staleCalls = 0;
|
|
$c->closeGeneration($task, static function () use (&$staleCalls): array { ++$staleCalls; return []; });
|
|
$s = $read($path);
|
|
$expect($staleCalls === 0 && $s['upstream']['task_id'] === 'private-independent-next' && !isset($s['producer_terminal_status'])
|
|
&& empty($s['transfer_closed']) && !$c->canStartAttempt(), 'late archived callbacks cannot close or poison active successor');
|
|
$expect($c->publicState()['lifecycle_conflict'] && count($s['completed_generations']) === 1, 'archive conflict remains public-safe across later generation');
|
|
$privacy($c);
|
|
|
|
// A stale exact response is not applied after claim/pair replacement.
|
|
foreach (['claim', 'generation', 'profile', 'user', 'run'] as $changed) {
|
|
[$c, $task, $path] = $make();
|
|
$r = $c->closeGeneration($task, static function (array $id) use ($changed, $path, $read, $write, $reply): array {
|
|
$s = $read($path);
|
|
if ($changed === 'claim') $s['verification_claim']['token'] = 'new-private-claim';
|
|
elseif ($changed === 'generation') ++$s['upstream']['generation'];
|
|
else $s['upstream'][$changed === 'run' ? 'workflow_run_id' : $changed] = 'private-independent-replacement';
|
|
$write($path, $s); return $reply($id, 'succeeded');
|
|
});
|
|
$expect($r['reason'] === 'stale_result' && isset($read($path)['upstream']) && !isset($read($path)['persisted_run_status']), 'stale exact reply cannot apply after ownership replacement ' . $changed);
|
|
}
|
|
|
|
[$c, $task, $path] = $make(); $calls = 0;
|
|
$r = $c->closeGeneration($task, static function (array $id) use ($reply, &$calls): array { ++$calls; $GLOBALS['r26iNs'] += 1000000001; return $reply($id, 'succeeded'); });
|
|
$s = $read($path);
|
|
$expect(!$r['confirmed'] && $r['reason'] === 'budget_exhausted' && $calls === 1 && !$c->canStartAttempt(), 'late terminal observation cannot confirm or start generation');
|
|
$expect($s['persisted_run_status'] === 'succeeded' && $s['run_status_observations'][0]['within_budget'] === false
|
|
&& empty($s['persisted_terminal_confirmed']), 'late safe observation retained explicitly outside proof budget');
|
|
|
|
// Global final envelope narrows the later target's otherwise fresh
|
|
// generation budget. It is not renewed by another final call.
|
|
[$c, $task, $path] = $make();
|
|
try { $c->bindTask('qwen', 'private-independent-user', 'private-independent-handoff', 'private-independent-handoff-run'); } catch (RuntimeException $e) {}
|
|
$c->finish('error'); $calls = 0; $timeouts = [];
|
|
$r = $c->reconcileClosedTasks(static function (array $id, int $ms) use (&$calls, &$timeouts, $reply): array {
|
|
++$calls; $timeouts[] = $ms;
|
|
$GLOBALS['r26iNs'] += ($calls <= 2 ? 600 : $ms) * 1000000;
|
|
return $reply($id, $calls === 2 ? 'running' : 'succeeded');
|
|
});
|
|
$s = $read($path); $globalStart = $s['closed_verification_started_at'];
|
|
$expect($calls === 3 && $timeouts === [1000, 1000, 550] && $r['elapsed_ms'] === 2000 && $r['budget_exhausted'], 'one global two-second recovery budget bounds all original pairs');
|
|
$expect($r['upstream_pending_task_count'] === 1 && count($s['completed_generations']) === 1, 'late second target stays pending while confirmed first remains archived');
|
|
$GLOBALS['r26iNs'] += 3000000000; $c->reconcileClosedTasks(static function () use (&$calls): array { ++$calls; return []; });
|
|
$expect($calls === 3 && $read($path)['closed_verification_started_at'] === $globalStart, 'whole recovery deadline and per-target attempts are never renewed');
|
|
|
|
foreach (['task_only', 'old_sse_flag'] as $kind) {
|
|
[$c, $task, $path, $uuid] = $make($kind === 'task_only' ? '' : 'private-independent-run');
|
|
if ($kind === 'old_sse_flag') { $s = $read($path); $s['upstream_terminal_confirmed'] = true; $s['upstream_terminal_status'] = 'stopped'; $write($path, $s); }
|
|
$c->markTerminal($task, 'stopped', $kind === 'task_only' ? '' : 'private-independent-run');
|
|
$c->clearTask($task); $s = $read($path); $s['expires_at'] = time() - 1200; $write($path, $s); touch($path, time() - 1200);
|
|
$peer = new Control(17, 11, $uuid, $directory);
|
|
$expect(isset($read($path)['upstream']) && !$peer->publicState()['upstream_terminal_confirmed'] && !$peer->begin(), 'legacy producer-only state survives expiry without independent proof ' . $kind);
|
|
if ($kind === 'task_only') { $calls = 0; $r = $peer->closeGeneration($task, static function () use (&$calls): array { ++$calls; return []; });
|
|
$expect($calls === 0 && $r['reason'] === 'identity_unavailable', 'missing original run never guessed or queried'); }
|
|
$privacy($peer);
|
|
}
|
|
foreach ($pins as $path => $sha) $expect(hash_file('sha256', $root . '/' . $path) === $sha, 'exact reviewed source pin after ' . $path);
|
|
$expect(!class_exists('think\\App', false) && !class_exists('app\\common\\service\\DifyChatService', false), 'Control test never loads framework/provider/config');
|
|
echo json_encode(['scope' => 'independent r26 private lifecycle state adversarial review', 'assertions' => $checks,
|
|
'failures' => $failures, 'network_calls' => 0, 'model_calls' => 0], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), "\n";
|
|
} finally {
|
|
$real = realpath($directory); $temp = realpath(sys_get_temp_dir());
|
|
if ($real !== false) {
|
|
if ($temp === false || !str_starts_with(str_replace('\\', '/', $real), rtrim(str_replace('\\', '/', $temp), '/') . '/r26-independent-lifecycle-')) throw new RuntimeException('Synthetic cleanup boundary');
|
|
foreach (new DirectoryIterator($real) as $entry) { if ($entry->isDot()) continue; if ($entry->isLink() || !$entry->isFile()) throw new RuntimeException('Unexpected synthetic cleanup entry'); unlink($entry->getPathname()); }
|
|
rmdir($real);
|
|
}
|
|
}
|
|
exit($failures === [] ? 0 : 1);
|
|
}
|