'text', 'phase' => 'waiting', 'completed_units' => 2, 'total_units' => 5, 'stage_started_at' => $now - 80, 'updated_at' => $now - 60, 'steps' => [['value' => ['content' => $private]]], 'notice' => $private, 'model_key' => $private, 'source_hash' => $private, 'lock_token' => $private, 'stage_label' => $private, 'usage' => [$private]]; $meta = Progress::sanitize($raw); $expect(array_keys($meta) === ['stage', 'phase', 'completed_units', 'total_units', 'stage_started_at', 'updated_at'], 'storage accepts only the fixed metadata keys'); $expect(!str_contains(json_encode($meta), 'private') && strlen(json_encode($meta)) < 2048, 'storage has no free text or model cache'); foreach ($meta as $value) { $expect(is_scalar($value) || $value === null, 'metadata is scalar only'); } $task = ['status' => 'running', 'started_at' => $now - 120, 'updated_at' => $now - 2, 'progress_json' => json_encode($raw), 'attempts' => 1, 'total_attempts' => 4, 'lock_until' => $now - 10]; $result = Progress::task($task, $now); $expect($result['elapsed_seconds'] === 120 && $result['stage_elapsed_seconds'] === 80 && $result['completed_units'] === 2 && $result['total_units'] === 5 && $result['attempt'] === 4, 'measured stage and current-attempt timing use trusted clocks'); $expect($result['phase'] === 'waiting' && str_contains($result['notice'], '等待模型返回') && !str_contains(json_encode($result), 'private'), 'API labels and waiting notices are authored locally'); $expect(!array_key_exists('percent', $result) && !array_key_exists('progress_cipher', $result), 'no invented overall percentage or encrypted cache'); $stale = Progress::task($task, $now + 100); $expect(str_contains($stale['notice'], '暂无新的进度更新') && !str_contains($stale['notice'], '超时') && !str_contains($stale['notice'], '失联') && $stale['stage'] === 'text', 'quiet progress never diagnoses a dead worker or timeout'); $terminalExpected = ['success' => ['completed', 'completed'], 'failed' => ['failed', 'failed'], 'cancelled' => ['cancelled', 'failed'], 'queued' => ['queued', 'waiting'], 'retry_wait' => ['retry_wait', 'waiting']]; foreach ($terminalExpected as $status => [$stage, $phase]) { $result = Progress::task(array_replace($task, ['status' => $status, 'finished_at' => $now - 20, 'next_run_at' => $now + 30]), $now); $expect($result['stage'] === $stage && $result['phase'] === $phase && $result['completed_units'] === null, 'task state overrides stale counters: ' . $status); if (in_array($status, ['success', 'failed', 'cancelled'], true)) { $expect($result['elapsed_seconds'] === 100, 'terminal duration stops advancing: ' . $status); } } $retry = Progress::task(array_replace($task, ['status' => 'retry_wait', 'next_run_at' => $now + 30, 'error_code' => 'BUDGET_PAUSED']), $now); $expect($retry['wait_remaining_seconds'] === 30 && str_contains($retry['notice'], '额度'), 'retry waiting explains scheduled budget pause'); $expect(str_contains($retry['notice'], '上次进度:整理文字资料') && str_contains($retry['notice'], '本次尝试'), 'waiting retries retain a trusted stage description and identify attempt timing'); $finishedFailure = array_replace($task, ['status' => 'failed', 'finished_at' => $now - 20, 'updated_at' => $now - 1]); $expect(Progress::task($finishedFailure, $now + 1000)['elapsed_seconds'] === 100, 'historical terminal duration uses finish time despite later metadata updates or polling'); $finishedRetry = array_replace($finishedFailure, ['status' => 'retry_wait', 'next_run_at' => $now + 30]); $expect(Progress::task($finishedRetry, $now + 1000)['elapsed_seconds'] === 100, 'scheduled retry backoff does not inflate the previous model attempt duration'); $expect(Progress::task(array_replace($task, ['status' => 'retry_wait', 'next_run_at' => $now - 30]), $now)['wait_remaining_seconds'] === 0, 'elapsed retry deadlines do not become negative'); foreach ([null, '{}', str_repeat('x', 3000), '{invalid', json_encode(['stage' => 'completed', 'phase' => 'completed'])] as $old) { $result = Progress::task(array_replace($task, ['progress_json' => $old]), $now); $expect($result['stage'] === 'unknown' && $result['phase'] === 'running' && $result['stage_elapsed_seconds'] === null, 'old/missing/invalid progress cannot claim current task completion'); } $bad = Progress::sanitize(['stage' => $private, 'phase' => $private, 'completed_units' => $private, 'total_units' => [], 'updated_at' => [], 'stage_started_at' => -2]); $expect($bad['stage'] === 'unknown' && $bad['phase'] === 'running' && $bad['completed_units'] === null && $bad['updated_at'] === 0, 'malformed metadata is harmless'); $expect(Progress::sanitize(['stage' => 'text', 'completed_units' => 9, 'total_units' => 2])['completed_units'] === 2, 'bounded counters cannot exceed their stage total'); $fresh = Progress::advance($meta, 'text', 'running', 0, 5, $now, true); $expect($fresh['stage_started_at'] === $now, 'new attempt/round explicitly resets timing even for the same stage'); $expect(Progress::advance($fresh, 'text', 'waiting', 0, 5, $now + 5)['stage_started_at'] === $now, 'waiting and parsing transitions retain measured stage start'); $future = Progress::task(array_replace($task, ['started_at' => $now + 50, 'progress_json' => json_encode(Progress::advance([], 'files', 'waiting', 0, 2, $now + 60))]), $now); $expect($future['elapsed_seconds'] === 0 && $future['stage_elapsed_seconds'] === 0 && $future['updated_at'] === $now, 'clock skew cannot produce negative elapsed time or future update labels'); $batch = ['status' => 'waiting_sources', 'wait_until' => $now + 15, 'created_at' => $now - 90, 'updated_at' => $now - 3]; $waiting = Progress::batch($batch, $now); $expect($waiting['stage'] === 'waiting_sources' && $waiting['wait_remaining_seconds'] === 15 && $waiting['stage_elapsed_seconds'] === null && str_contains($waiting['notice'], '自动'), 'waiting source deadline is visible without inventing a polling stage start'); $expired = Progress::batch($batch, $now + 16); $expect($expired['wait_remaining_seconds'] === 0 && str_contains($expired['notice'], '期限已到') && $expired['stage'] === 'waiting_sources', 'expired source deadline stays waiting until coordinator really advances'); $historyBatch = Progress::batch(['status' => 'success', 'validity' => 'source_updated', 'created_at' => $now - 200, 'updated_at' => $now - 1], $now, ['qwen' => ['progress' => ['phase' => 'completed', 'updated_at' => $now - 50]], 'openai' => ['progress' => ['phase' => 'completed', 'updated_at' => $now - 60]]]); $expect($historyBatch['elapsed_seconds'] === 150, 'later source validity updates cannot inflate historical batch completion duration'); echo "Prescription AI progress: {$checks} checks passed\n";