Files
zyt/server/tests/AssistantRequestTerminalReconciliationTest.php
T
2026-10-27 14:03:35 +08:00

327 lines
22 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\service\AssistantRequestControl;
// Synthetic private controls only. No app runtime, config, HTTP/socket, model,
// database, credential or production-control access is used by this suite.
$assertions = 0;
function identityExpect(bool $ok, string $message): void {
++$GLOBALS['assertions'];
if (!$ok) throw new RuntimeException($message);
}
$directory = sys_get_temp_dir() . '/zyt-terminal-identity-' . bin2hex(random_bytes(8));
$number = 0;
$make = static function (string $run = 'synthetic-run', bool $closed = true) use ($directory, &$number): array {
++$number;
$uuid = sprintf('19345678-1234-4234-8234-%012d', $number);
$control = new AssistantRequestControl(7, 11, $uuid, $directory);
identityExpect($control->begin(), 'new synthetic request begins');
$task = 'synthetic-task-' . $number;
$control->bindTask('qwen', 'synthetic-user', $task, $run);
if ($closed) $control->finish('error');
return [$control, $directory . '/' . hash('sha256', '7:11:' . $uuid) . '.json', $task, $uuid];
};
$read = static fn (string $path): array => json_decode((string) 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)); };
$blocked = static function (AssistantRequestControl $control, string $task, string $run = ''): void {
try {
$control->bindTask('qwen', 'synthetic-user', $task, $run);
identityExpect(false, 'unexpected created task must remain blocked');
} catch (RuntimeException $e) {
identityExpect($e->getMessage() === 'UPSTREAM_TASK_HANDOFF_BLOCKED', 'handoff remains blocked after recording identity');
}
};
try {
[$control, $path, $task] = $make('', false);
$blocked($control, 'synthetic-handoff');
$control->signalCancel();
$generation = $read($path)['upstream']['generation'];
$control->stopBoundTask(static function (array $snapshot) use ($control, $path, $task, $read, $generation): bool {
$before = $read($path);
$control->bindTask('qwen', 'synthetic-user', $task, 'synthetic-enriched-run');
$after = $read($path);
identityExpect($after['upstream']['generation'] === $generation, 'run enrichment keeps the same generation');
foreach (['cancel_requested', 'stop_claim', 'stop_attempts', 'stop_retry_until', 'handoff_cleanup'] as $field) {
identityExpect(($before[$field] ?? null) === ($after[$field] ?? null), 'run enrichment preserves ' . $field);
}
identityExpect($snapshot['task_id'] === $task, 'stop snapshot keeps original task');
return true;
}, $task);
$blocked($control, 'synthetic-handoff', 'synthetic-handoff-run');
$handoff = $read($path)['handoff_cleanup']['synthetic-handoff'];
identityExpect($handoff['upstream']['workflow_run_id'] === 'synthetic-handoff-run'
&& $handoff['stop_attempts'] === 0 && $handoff['stop_requested'], 'existing handoff enriches without resetting cleanup intent');
$before = $read($path);
$control->bindTask('qwen', 'synthetic-user', $task, 'synthetic-enriched-run');
$control->bindTask('qwen', 'synthetic-user', $task);
identityExpect($read($path)['upstream'] === $before['upstream'], 'duplicate and task-only bind cannot erase the captured run');
identityExpect($read($path)['upstream_stop_confirmed'] && $read($path)['stop_attempts'] === 1
&& $control->shouldAbort(), 'same-pair rebinding preserves accepted stop, attempts and cancellation');
foreach ([[$task, 'synthetic-conflict-run'], ['synthetic-handoff', 'synthetic-conflict-run']] as [$conflictTask, $conflictRun]) {
try {
$control->bindTask('qwen', 'synthetic-user', $conflictTask, $conflictRun);
identityExpect(false, 'conflicting run must throw');
} catch (RuntimeException $e) {
identityExpect($e->getMessage() === 'UPSTREAM_TASK_IDENTITY_CONFLICT', 'conflicting run fails closed');
}
}
identityExpect($read($path)['upstream'] === $before['upstream']
&& $read($path)['handoff_cleanup'] === $before['handoff_cleanup'], 'conflicting run never overwrites either target');
foreach ([['openai', 'synthetic-user'], ['qwen', 'foreign-user']] as [$profile, $user]) {
try {
$control->bindTask($profile, $user, $task, 'synthetic-enriched-run');
identityExpect(false, 'same task under another authorization scope must throw');
} catch (RuntimeException $e) {
identityExpect($e->getMessage() === 'UPSTREAM_TASK_IDENTITY_CONFLICT', 'task identity cannot migrate across profile/user scope');
}
}
$control->markTerminal($task, 'succeeded');
$control->markTerminal($task, 'succeeded', 'synthetic-wrong-run');
$control->markTerminal($task, 'running', 'synthetic-enriched-run');
$control->clearTask($task);
identityExpect($control->publicState()['upstream_pending_task_count'] === 2, 'missing/wrong run and running status cannot release either target');
$control->markTerminal('synthetic-handoff', 'stopped', 'synthetic-handoff-run');
$control->clearTask('synthetic-handoff');
identityExpect($control->publicState()['upstream_pending_task_count'] === 1, 'handoff terminal never confirms primary');
$control->markTerminal($task, 'failed', 'synthetic-enriched-run');
$control->markTerminal($task, 'succeeded', 'synthetic-enriched-run');
identityExpect($control->publicState()['upstream_terminal_status'] === 'failed', 'first exact terminal status is immutable');
[$late, $latePath, $lateTask] = $make('', false);
// A separate empty request models metadata discovered after timeout.
$late->markTerminal($lateTask, 'stopped'); $late->clearTask($lateTask);
$lateState = $read($latePath); unset($lateState['task_generation']); $write($latePath, $lateState);
$late->signalTimeout();
$late->bindTask('qwen', 'synthetic-user', 'synthetic-late-first', 'synthetic-late-run');
identityExpect(($read($latePath)['upstream']['task_id'] ?? '') === 'synthetic-late-first'
&& $late->publicState()['timeout_reached'] && !$late->shouldAbort(), 'late first pair remains primary without user cancel');
foreach (['../invalid', 'https://invalid', str_repeat('x', 129)] as $invalid) {
try {
$late->bindTask('qwen', 'synthetic-user', 'synthetic-late-first', $invalid);
identityExpect(false, 'invalid run must throw');
} catch (RuntimeException $e) {
identityExpect($e->getMessage() === 'Invalid upstream task identity', 'bounded run identifier syntax rejects unsafe values');
}
}
[$strict, $strictPath, $strictTask] = $make('', false);
$blocked($strict, 'synthetic-strict-handoff');
$strict->signalCancel();
$strict->stopBoundTask(static fn (): bool => true, $strictTask);
$strictHash = hash_file('sha256', $strictPath);
foreach (["synthetic-id\n", "synthetic-id\r\n", "\nsynthetic-id", "synthetic\nid", str_repeat('x', 128) . "\n"] as $malformed) {
foreach (['task', 'primary_run', 'handoff_run'] as $field) {
$rejected = false;
try {
$strict->bindTask('qwen', 'synthetic-user',
$field === 'task' ? $malformed : ($field === 'primary_run' ? $strictTask : 'synthetic-strict-handoff'),
$field === 'task' ? '' : $malformed);
} catch (RuntimeException $e) {
$rejected = $e->getMessage() === 'Invalid upstream task identity';
}
identityExpect($rejected, $field . ': newline identifier fails strict whole-string validation');
identityExpect(hash_file('sha256', $strictPath) === $strictHash,
$field . ': rejected identifier leaves primary/handoff, stop acceptance and cancellation byte-identical');
}
}
[$legacy, $legacyPath, $legacyTask] = $make('');
$legacyCalls = 0;
$legacyResult = $legacy->reconcileTerminal($legacyTask, static function () use (&$legacyCalls): array {
++$legacyCalls; return ['workflow_run_id' => 'guessed', 'status' => 'stopped'];
});
identityExpect($legacyCalls === 0 && $legacyResult['reason'] === 'identity_unavailable'
&& $legacy->publicState()['upstream_pending_task_count'] === 1, 'legacy missing-pair evidence cannot authorize independent lookup');
$legacyState = $read($legacyPath); $legacyState['expires_at'] = time() - 10;
unset($legacyState['begun']); $write($legacyPath, $legacyState); touch($legacyPath, time() - 700);
new AssistantRequestControl(7, 11, sprintf('19345678-1234-4234-8234-%012d', $number), $directory);
identityExpect(is_file($legacyPath) && !$legacy->begin() && $legacy->publicState()['upstream_pending_task_count'] === 1,
'expired legacy missing-pair tombstone survives GC and blocks replay even without begun metadata');
$legacy->markTerminal($legacyTask, 'stopped', 'guessed');
identityExpect(!$legacy->publicState()['upstream_terminal_confirmed'], 'terminal cannot invent the legacy run binding');
$legacy->markTerminal($legacyTask, 'stopped');
identityExpect($legacy->publicState()['upstream_terminal_confirmed'], 'explicit legacy task-only live receipt stays compatible');
[$open, $openPath, $openTask] = $make('synthetic-open-run', false);
$calls = 0;
$openResult = $open->reconcileTerminal($openTask, static function () use (&$calls): array { ++$calls; return []; });
identityExpect($calls === 0 && $openResult['reason'] === 'transport_open', 'reconciliation cannot extend live-stream drain');
foreach (['succeeded', 'failed', 'stopped'] as $status) {
[$exact, $exactPath, $exactTask] = $make('synthetic-exact-' . $status);
$exact->signalTimeout();
$calls = 0;
$result = $exact->reconcileTerminal($exactTask, static function (array $identity, int $timeoutMs) use ($exact, &$calls, $status): array {
++$calls;
identityExpect($timeoutMs > 0 && $timeoutMs <= 1000 && count($identity) === 5, 'adapter receives a bounded exact private snapshot');
identityExpect($exact->publicState()['transport_closed'], 'adapter executes outside request and GC locks');
$nested = $exact->reconcileTerminal($identity['task_id'], static function (): array {
identityExpect(false, 'active verifier cannot dispatch a duplicate lookup'); return [];
});
identityExpect($nested['reason'] === 'verification_in_progress', 'distinct verifier lease prevents duplicate reads');
return $identity + ['status' => $status, 'response_body' => 'synthetic-secret-body'];
});
$public = $exact->publicState();
identityExpect($calls === 1 && $result['confirmed'] && $public['upstream_pending_task_count'] === 0
&& $public['status'] === 'error' && $public['timeout_reached'] && !$public['cancel_requested'], 'exact run terminal reconciles remote state without changing failed report intent');
identityExpect(!isset($read($exactPath)['upstream']) && $public['upstream_terminal_status'] === $status,
'authoritative exact terminal releases only the verified identity');
$exact->reconcileTerminal($exactTask, static function () use (&$calls): array { ++$calls; return []; });
identityExpect($calls === 1, 'released identity never triggers another read');
identityExpect(!str_contains((string) file_get_contents($exactPath), 'synthetic-secret-body'), 'adapter response body is never persisted');
}
$badResponses = [
'running' => ['status' => 'running'], 'unknown' => ['status' => 'complete'],
'cancelled' => ['status' => 'cancelled'], 'missing_status' => [],
'wrong_run' => ['workflow_run_id' => 'foreign-run', 'status' => 'stopped'],
'missing_run' => ['workflow_run_id' => null, 'status' => 'stopped'],
'wrong_task' => ['task_id' => 'foreign-task', 'status' => 'stopped'],
'wrong_user' => ['user' => 'foreign-user', 'status' => 'stopped'],
'wrong_profile' => ['profile' => 'openai', 'status' => 'stopped'],
'wrong_type' => ['profile' => [], 'status' => 'stopped'],
'explicit_failure' => ['ok' => false, 'status' => 'stopped'],
'malformed' => 'synthetic-untrusted-body', 'null' => null,
];
foreach ($badResponses as $label => $response) {
[$bad, $badPath, $badTask] = $make('synthetic-bad-run');
$before = $read($badPath);
$times = [];
$result = $bad->reconcileTerminal($badTask, static function (array $identity) use ($response, &$times) {
$times[] = hrtime(true) / 1e9;
return is_array($response) ? $response + ['workflow_run_id' => $identity['workflow_run_id']] : $response;
});
identityExpect(count($times) === 2 && $times[1] - $times[0] >= 0.249 && !$result['confirmed']
&& $bad->publicState()['upstream_pending_task_count'] === 1, $label . ': invalid/nonterminal result stays pending under two spaced reads');
$beforeAttempts = $read($badPath)['verification_attempts'];
$bad->reconcileTerminal($badTask, static function () use (&$times): array { $times[] = 0; return []; });
identityExpect(count($times) === 2 && $read($badPath)['verification_attempts'] === $beforeAttempts,
$label . ': external invocation cannot reset read attempts');
if ($label === 'explicit_failure') {
$after = $read($badPath);
foreach (['upstream', 'status', 'transport_closed', 'upstream_stop_confirmed', 'upstream_terminal_confirmed'] as $field) {
identityExpect(($after[$field] ?? null) === ($before[$field] ?? null),
'explicit adapter ok=false with an exact run and terminal status preserves ' . $field);
}
}
}
[$throwing, $throwingPath, $throwingTask] = $make('synthetic-throw-run');
$result = $throwing->reconcileTerminal($throwingTask, static function (): array { throw new RuntimeException('synthetic-secret-exception'); });
identityExpect(!$result['confirmed'] && $throwing->publicState()['upstream_pending_task_count'] === 1
&& !str_contains((string) file_get_contents($throwingPath), 'synthetic-secret-exception'), 'callback exceptions fail closed without persisting error prose');
[$budget, $budgetPath, $budgetTask] = $make('synthetic-budget-run');
$timeouts = [];
$result = $budget->reconcileTerminal($budgetTask, static function (array $identity, int $timeoutMs) use (&$timeouts): array {
$timeouts[] = $timeoutMs;
if (count($timeouts) === 1) { usleep(800000); return $identity + ['status' => 'running']; }
return $identity + ['status' => 'succeeded'];
});
identityExpect($result['confirmed'] && count($timeouts) === 2 && $timeouts[1] < 1000 && $timeouts[1] > 500,
'retry receives only remaining shared budget after first I/O and spacing');
[$slow, $slowPath, $slowTask] = $make('synthetic-slow-run');
$result = $slow->reconcileTerminal($slowTask, static function (array $identity): array {
usleep(1020000); return $identity + ['status' => 'succeeded'];
});
identityExpect(!$result['confirmed'] && $slow->publicState()['upstream_pending_task_count'] === 1,
'over-budget callback cannot apply an exact terminal');
$slowState = $read($slowPath); $slowState['verification_started_at'] = hrtime(true) / 1e9 - 3; $write($slowPath, $slowState);
$calls = 0;
$result = $slow->reconcileTerminal($slowTask, static function () use (&$calls): array { ++$calls; return []; });
identityExpect($calls === 0 && $result['reason'] === 'budget_exhausted', 'external invocation cannot renew an expired monotonic budget');
foreach (['generation', 'task_id', 'workflow_run_id', 'profile', 'user', 'token'] as $changed) {
[$stale, $stalePath, $staleTask] = $make('synthetic-stale-run');
$result = $stale->reconcileTerminal($staleTask, static function (array $identity) use ($stalePath, $read, $write, $changed): array {
$state = $read($stalePath);
if ($changed === 'generation') ++$state['upstream']['generation'];
elseif ($changed === 'token') $state['verification_claim']['token'] = 'replacement-token';
else $state['upstream'][$changed] = 'replacement';
$write($stalePath, $state);
return $identity + ['status' => 'stopped'];
});
identityExpect($result['reason'] === 'stale_result' && !$stale->publicState()['upstream_terminal_confirmed'],
'stale ' . $changed . ' callback cannot confirm a replacement identity/claim');
}
[$abandoned, $abandonedPath, $abandonedTask] = $make('synthetic-abandoned-run');
$state = $read($abandonedPath); $now = hrtime(true) / 1e9;
$state['verification_started_at'] = $now - 1.3;
$state['verification_attempts'] = 1;
$state['verification_claim'] = ['token' => 'abandoned-token', 'until' => $now - 0.05];
$write($abandonedPath, $state); $calls = 0;
$result = $abandoned->reconcileTerminal($abandonedTask, static function (array $identity, int $timeoutMs) use (&$calls): array {
++$calls;
identityExpect($timeoutMs <= 700 && $timeoutMs > 0, 'crashed verifier takeover inherits remaining budget');
return $identity + ['status' => 'stopped'];
});
identityExpect($calls === 1 && $result['confirmed'] && $read($abandonedPath)['verification_attempts'] === 2,
'expired verification lease permits one remaining read without resetting attempts');
[$reboot, $rebootPath, $rebootTask] = $make('synthetic-clock-reset-run');
$state = $read($rebootPath); $state['verification_started_at'] = hrtime(true) / 1e9 + 100; $write($rebootPath, $state);
$calls = 0;
$result = $reboot->reconcileTerminal($rebootTask, static function () use (&$calls): array { ++$calls; return []; });
identityExpect($calls === 0 && $result['reason'] === 'budget_exhausted', 'monotonic clock reset fails closed without renewing budget');
[$handoffControl, $handoffPath, $primaryTask] = $make('synthetic-primary-run');
$blocked($handoffControl, 'synthetic-other-task', 'synthetic-other-run');
$state = $read($handoffPath); $state['stop_claim'] = ['token' => 'synthetic-stop-token', 'until' => microtime(true) + 1];
$state['stop_attempts'] = 1; $write($handoffPath, $state);
$result = $handoffControl->reconcileTerminal('synthetic-other-task', static fn (array $identity): array => $identity + ['status' => 'stopped']);
$state = $read($handoffPath);
identityExpect($result['confirmed'] && $handoffControl->publicState()['upstream_pending_task_count'] === 1
&& $state['upstream']['task_id'] === $primaryTask && $state['stop_claim']['token'] === 'synthetic-stop-token'
&& $state['stop_attempts'] === 1, 'handoff verification is isolated from primary identity and stop lease');
// Expired unresolved primary and handoff evidence must survive read/reset/GC/replay.
foreach (['primary', 'handoff'] as $kind) {
[$retained, $retainedPath, $retainedTask, $uuid] = $make('synthetic-retained-run');
if ($kind === 'handoff') {
$blocked($retained, 'synthetic-retained-handoff', 'synthetic-retained-handoff-run');
$retained->markTerminal($retainedTask, 'stopped', 'synthetic-retained-run');
$retained->clearTask($retainedTask);
}
$state = $read($retainedPath); $state['expires_at'] = time() - 100; $write($retainedPath, $state); touch($retainedPath, time() - 700);
$hash = hash_file('sha256', $retainedPath);
$reloaded = new AssistantRequestControl(7, 11, $uuid, $directory);
identityExpect(is_file($retainedPath) && $reloaded->publicState()['upstream_pending_task_count'] === 1
&& hash_file('sha256', $retainedPath) === $hash, $kind . ': GC and read-only status retain expired evidence byte-for-byte');
identityExpect(!$reloaded->begin() && !$reloaded->canStartAttempt(), $kind . ': expired unresolved request cannot replay');
}
foreach (['empty', 'confirmed'] as $kind) {
[$expired, $expiredPath, $expiredTask, $uuid] = $make('synthetic-expired-run');
if ($kind === 'confirmed') $expired->markTerminal($expiredTask, 'stopped', 'synthetic-expired-run');
else { $state = $read($expiredPath); unset($state['upstream']); $write($expiredPath, $state); }
$state = $read($expiredPath); $state['expires_at'] = time() - 100; $write($expiredPath, $state); touch($expiredPath, time() - 700);
new AssistantRequestControl(7, 11, $uuid, $directory);
identityExpect(!is_file($expiredPath), $kind . ': normal expired resolved/empty controls remain GC eligible');
}
[$reset, $resetPath] = $make('synthetic-reset-run');
$reset->markTerminal('not-own-task', 'stopped', 'synthetic-reset-run');
$state = $read($resetPath); $state['upstream_terminal_confirmed'] = true; $state['expires_at'] = time() - 1; $write($resetPath, $state);
identityExpect($reset->publicState()['status'] === 'pending' && $reset->begin(), 'normal expired resolved control still resets without waiting for GC');
$serialized = json_encode($handoffControl->publicState()) . json_encode($result);
foreach (['synthetic-primary-run', 'synthetic-other-run', 'synthetic-user', $primaryTask, 'response_body', 'verification_claim'] as $private) {
identityExpect(!str_contains($serialized, $private), 'public state/result excludes private identity or response: ' . $private);
}
$controller = (string) file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php');
identityExpect(str_contains($controller, '$control->bindTask($profile, $upstreamUser, $taskId, $workflowRunId)')
&& str_contains($controller, '$control->markTerminal($taskId, $status, $workflowRunId)'), 'authorized controller forwards captured run on both callbacks');
echo 'Assistant request exact identity / finite reconciliation / retention: OK (' . $assertions . " assertions)\n";
} finally {
foreach (glob($directory . '/*') ?: [] as $path) unlink($path);
if (is_file($directory . '/.gc.lock')) unlink($directory . '/.gc.lock');
if (is_dir($directory)) rmdir($directory);
}