scripts/ci-poll-decide.mjs
scripts/ci-poll-decide.mjsBrowse 4 files
4,130 tokens
15,989 bytes
Token encoding: o200k_base
Snapshot 646e806
← Back to SKILL.md
1#!/usr/bin/env node2 3/**4 * CI Poll Decision Script5 *6 * Deterministic decision engine for CI monitoring.7 * Takes ci_information JSON + state args, outputs a single JSON action line.8 *9 * Architecture:10 * classify() — pure decision tree, returns { action, code, extra? }11 * buildOutput() — maps classification to full output with messages, delays, counters12 *13 * Usage:14 * node ci-poll-decide.mjs '<ci_info_json>' <poll_count> <verbosity> \15 * [--wait-mode] [--prev-cipe-url <url>] [--expected-sha <sha>] \16 * [--prev-status <status>] [--timeout <minutes>] [--new-cipe-timeout <minutes>] \17 * [--elapsed-seconds <n>] [--env-rerun-count <n>] [--no-progress-count <n>] \18 * [--prev-cipe-status <status>] [--prev-sh-status <status>] \19 * [--prev-verification-status <status>] [--prev-failure-classification <status>]20 *21 * Note: --timeout and --new-cipe-timeout are accepted in MINUTES (matching the22 * skill's documented flags) and converted to seconds internally. --elapsed-seconds23 * is the wall-clock time since monitoring began, carried across attempts by the24 * orchestrator, and is the authoritative signal for the --timeout budget.25 */26 27// --- Arg parsing ---28 29const args = process.argv.slice(2);30const ciInfoJson = args[0];31const pollCount = parseInt(args[1], 10) || 0;32const verbosity = args[2] || 'medium';33 34function getFlag(name) {35 return args.includes(name);36}37 38function getArg(name) {39 const idx = args.indexOf(name);40 return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;41}42 43const waitMode = getFlag('--wait-mode');44const prevCipeUrl = getArg('--prev-cipe-url');45const expectedSha = getArg('--expected-sha');46const prevStatus = getArg('--prev-status');47// Flags are documented in minutes; convert to seconds for internal comparison.48const timeoutSeconds = parseInt(getArg('--timeout') || '0', 10) * 60;49const newCipeTimeoutSeconds =50 parseInt(getArg('--new-cipe-timeout') || '0', 10) * 60;51// Wall-clock seconds since monitoring began (carried across attempts); null when52// the orchestrator doesn't supply it (see isTimedOut for that fallback).53const elapsedArg = getArg('--elapsed-seconds');54const elapsedSeconds = elapsedArg !== null ? parseInt(elapsedArg, 10) : null;55const envRerunCount = parseInt(getArg('--env-rerun-count') || '0', 10);56const inputNoProgressCount = parseInt(getArg('--no-progress-count') || '0', 10);57const prevCipeStatus = getArg('--prev-cipe-status');58const prevShStatus = getArg('--prev-sh-status');59const prevVerificationStatus = getArg('--prev-verification-status');60const prevFailureClassification = getArg('--prev-failure-classification');61 62// --- Parse CI info ---63 64let ci;65try {66 ci = JSON.parse(ciInfoJson);67} catch {68 console.log(69 JSON.stringify({70 action: 'done',71 code: 'error',72 message: 'Failed to parse ci_information JSON',73 noProgressCount: inputNoProgressCount + 1,74 envRerunCount,75 })76 );77 process.exit(0);78}79 80const {81 cipeStatus,82 selfHealingStatus,83 verificationStatus,84 selfHealingEnabled,85 selfHealingSkippedReason,86 failureClassification: rawFailureClassification,87 failedTaskIds = [],88 verifiedTaskIds = [],89 couldAutoApplyTasks,90 autoApplySkipped,91 autoApplySkipReason,92 userAction,93 cipeUrl,94 commitSha,95} = ci;96 97const failureClassification = rawFailureClassification?.toLowerCase() ?? null;98 99// --- Helpers ---100 101function categorizeTasks() {102 const verifiedSet = new Set(verifiedTaskIds);103 const unverified = failedTaskIds.filter((t) => !verifiedSet.has(t));104 if (unverified.length === 0) return { category: 'all_verified' };105 106 const e2e = unverified.filter((t) => {107 const parts = t.split(':');108 return parts.length >= 2 && parts[1].includes('e2e');109 });110 if (e2e.length === unverified.length) return { category: 'e2e_only' };111 112 const verifiable = unverified.filter((t) => {113 const parts = t.split(':');114 return !(parts.length >= 2 && parts[1].includes('e2e'));115 });116 return { category: 'needs_local_verify', verifiableTaskIds: verifiable };117}118 119function backoff(count) {120 const delays = [60, 90, 120, 180];121 return delays[Math.min(count, delays.length - 1)];122}123 124function hasStateChanged() {125 if (prevCipeStatus && cipeStatus !== prevCipeStatus) return true;126 if (prevShStatus && selfHealingStatus !== prevShStatus) return true;127 if (prevVerificationStatus && verificationStatus !== prevVerificationStatus)128 return true;129 if (130 prevFailureClassification &&131 failureClassification !== prevFailureClassification132 )133 return true;134 return false;135}136 137function isTimedOut() {138 if (timeoutSeconds <= 0) return false;139 // Prefer real wall-clock elapsed (carried across attempts) so --timeout caps140 // total monitor duration, not a single invocation.141 if (elapsedSeconds !== null && !Number.isNaN(elapsedSeconds))142 return elapsedSeconds >= timeoutSeconds;143 // Fallback: estimate elapsed from poll cadence within this invocation.144 const avgDelay = pollCount === 0 ? 0 : backoff(Math.floor(pollCount / 2));145 return pollCount * avgDelay >= timeoutSeconds;146}147 148function isWaitTimedOut() {149 if (newCipeTimeoutSeconds <= 0) return false;150 return pollCount * 30 >= newCipeTimeoutSeconds;151}152 153function isNewCipe() {154 return (155 (prevCipeUrl && cipeUrl && cipeUrl !== prevCipeUrl) ||156 (expectedSha && commitSha && commitSha === expectedSha)157 );158}159 160// ============================================================161// classify() — pure decision tree162//163// Returns: { action: 'poll'|'wait'|'done', code: string, extra? }164//165// Decision priority (top wins):166// WAIT MODE:167// 1. new CI Attempt detected → poll (new_cipe_detected)168// 2. wait timed out → done (no_new_cipe)169// 3. still waiting → wait (waiting_for_cipe)170// NORMAL MODE:171// 4. polling timeout → done (polling_timeout)172// 5. circuit breaker (13 polls) → done (circuit_breaker)173// 6. CI succeeded → done (ci_success)174// 7. CI canceled → done (cipe_canceled)175// 8. CI timed out → done (cipe_timed_out)176// 9. CI failed, no tasks recorded → done (cipe_no_tasks)177// 10. environment failure → done (environment_rerun_cap | environment_issue)178// 11. self-healing throttled → done (self_healing_throttled)179// 12. CI in progress / not started → poll (ci_running)180// 13. self-healing in progress → poll (sh_running)181// 14. flaky task auto-rerun → poll (flaky_rerun)182// 15. fix auto-applied → poll (fix_auto_applied)183// 16. auto-apply: skipped → done (fix_auto_apply_skipped)184// 17. auto-apply: verification pending→ poll (verification_pending)185// 18. auto-apply: verified → done (fix_auto_applying)186// 19. fix: verification failed/none → done (fix_needs_review)187// 20. fix: all/e2e verified → done (fix_apply_ready)188// 21. fix: needs local verify → done (fix_needs_local_verify)189// 22. self-healing failed → done (fix_failed)190// 23. no fix available → done (no_fix)191// 24. fallback → poll (fallback)192// ============================================================193 194function classify() {195 // --- Wait mode ---196 if (waitMode) {197 if (isNewCipe()) return { action: 'poll', code: 'new_cipe_detected' };198 // The total --timeout budget also caps time spent waiting for a new CI199 // Attempt, so it must win over --new-cipe-timeout here; otherwise a long200 // wait (or a sequence of apply→wait cycles) could run past --timeout.201 if (isTimedOut()) return { action: 'done', code: 'polling_timeout' };202 if (isWaitTimedOut()) return { action: 'done', code: 'no_new_cipe' };203 return { action: 'wait', code: 'waiting_for_cipe' };204 }205 206 // --- Guards ---207 if (isTimedOut()) return { action: 'done', code: 'polling_timeout' };208 if (noProgressCount >= 13) return { action: 'done', code: 'circuit_breaker' };209 210 // --- Terminal CI states ---211 if (cipeStatus === 'SUCCEEDED') return { action: 'done', code: 'ci_success' };212 if (cipeStatus === 'CANCELED')213 return { action: 'done', code: 'cipe_canceled' };214 if (cipeStatus === 'TIMED_OUT')215 return { action: 'done', code: 'cipe_timed_out' };216 217 // --- CI failed, no tasks ---218 if (219 cipeStatus === 'FAILED' &&220 failedTaskIds.length === 0 &&221 selfHealingStatus == null222 )223 return { action: 'done', code: 'cipe_no_tasks' };224 225 // --- Environment failure ---226 if (failureClassification === 'environment_state') {227 if (envRerunCount >= 2)228 return { action: 'done', code: 'environment_rerun_cap' };229 return { action: 'done', code: 'environment_issue' };230 }231 232 // --- Throttled ---233 if (selfHealingSkippedReason === 'THROTTLED')234 return { action: 'done', code: 'self_healing_throttled' };235 236 // --- Still running: CI ---237 if (cipeStatus === 'IN_PROGRESS' || cipeStatus === 'NOT_STARTED')238 return { action: 'poll', code: 'ci_running' };239 240 // --- Still running: self-healing ---241 if (242 (selfHealingStatus === 'IN_PROGRESS' ||243 selfHealingStatus === 'NOT_STARTED') &&244 !selfHealingSkippedReason245 )246 return { action: 'poll', code: 'sh_running' };247 248 // --- Still running: flaky rerun ---249 if (failureClassification === 'flaky_task')250 return { action: 'poll', code: 'flaky_rerun' };251 252 // --- Fix auto-applied, waiting for new CI Attempt ---253 if (userAction === 'APPLIED_AUTOMATICALLY')254 return { action: 'poll', code: 'fix_auto_applied' };255 256 // --- Auto-apply path (couldAutoApplyTasks) ---257 if (couldAutoApplyTasks === true) {258 if (autoApplySkipped === true)259 return {260 action: 'done',261 code: 'fix_auto_apply_skipped',262 extra: { autoApplySkipReason },263 };264 if (265 verificationStatus === 'NOT_STARTED' ||266 verificationStatus === 'IN_PROGRESS'267 )268 return { action: 'poll', code: 'verification_pending' };269 if (verificationStatus === 'COMPLETED')270 return { action: 'done', code: 'fix_auto_applying' };271 // verification FAILED or NOT_EXECUTABLE → falls through to fix_needs_review272 }273 274 // --- Fix available ---275 if (selfHealingStatus === 'COMPLETED') {276 if (277 verificationStatus === 'FAILED' ||278 verificationStatus === 'NOT_EXECUTABLE' ||279 (couldAutoApplyTasks !== true && !verificationStatus)280 )281 return { action: 'done', code: 'fix_needs_review' };282 283 const tasks = categorizeTasks();284 if (tasks.category === 'all_verified' || tasks.category === 'e2e_only')285 return { action: 'done', code: 'fix_apply_ready' };286 return {287 action: 'done',288 code: 'fix_needs_local_verify',289 extra: { verifiableTaskIds: tasks.verifiableTaskIds },290 };291 }292 293 // --- Fix failed ---294 if (selfHealingStatus === 'FAILED')295 return { action: 'done', code: 'fix_failed' };296 297 // --- No fix available ---298 if (299 cipeStatus === 'FAILED' &&300 (selfHealingEnabled === false || selfHealingStatus === 'NOT_EXECUTABLE')301 )302 return { action: 'done', code: 'no_fix' };303 304 // --- Fallback ---305 return { action: 'poll', code: 'fallback' };306}307 308// ============================================================309// buildOutput() — maps classification to full JSON output310// ============================================================311 312// Message templates keyed by status or key313const messages = {314 // wait mode315 new_cipe_detected: () =>316 `New CI Attempt detected! CI: ${cipeStatus || 'N/A'}`,317 no_new_cipe: () =>318 'New CI Attempt timeout exceeded. No new CI Attempt detected.',319 waiting_for_cipe: () => 'Waiting for new CI Attempt...',320 321 // guards322 polling_timeout: () => 'Polling timeout exceeded.',323 circuit_breaker: () => 'No progress after 13 consecutive polls. Stopping.',324 325 // terminal326 ci_success: () => 'CI passed successfully!',327 cipe_canceled: () => 'CI Attempt was canceled.',328 cipe_timed_out: () => 'CI Attempt timed out.',329 cipe_no_tasks: () => 'CI failed but no Nx tasks were recorded.',330 331 // environment332 environment_rerun_cap: () => 'Environment rerun cap (2) exceeded. Bailing.',333 environment_issue: () => 'CI: FAILED | Classification: ENVIRONMENT_STATE',334 335 // throttled336 self_healing_throttled: () =>337 'Self-healing throttled \u2014 too many unapplied fixes.',338 339 // polling340 ci_running: () => `CI: ${cipeStatus}`,341 sh_running: () => `CI: ${cipeStatus} | Self-healing: ${selfHealingStatus}`,342 flaky_rerun: () =>343 'CI: FAILED | Classification: FLAKY_TASK (auto-rerun in progress)',344 fix_auto_applied: () =>345 'CI: FAILED | Fix auto-applied, new CI Attempt spawning',346 verification_pending: () =>347 `CI: FAILED | Self-healing: COMPLETED | Verification: ${verificationStatus}`,348 349 // actionable350 fix_auto_applying: () => 'Fix verified! Auto-applying...',351 fix_auto_apply_skipped: (extra) =>352 `Fix verified but auto-apply was skipped. ${353 extra?.autoApplySkipReason354 ? `Reason: ${extra.autoApplySkipReason}`355 : 'Offer to apply manually.'356 }`,357 fix_needs_review: () =>358 `Fix available but needs review. Verification: ${359 verificationStatus || 'N/A'360 }`,361 fix_apply_ready: () => 'Fix available and verified. Ready to apply.',362 fix_needs_local_verify: (extra) =>363 `Fix available. ${extra.verifiableTaskIds.length} task(s) need local verification.`,364 fix_failed: () => 'Self-healing failed to generate a fix.',365 no_fix: () => 'CI failed, no fix available.',366 367 // fallback368 fallback: () =>369 `CI: ${cipeStatus || 'N/A'} | Self-healing: ${370 selfHealingStatus || 'N/A'371 } | Verification: ${verificationStatus || 'N/A'}`,372};373 374// Codes where noProgressCount resets to 0 (genuine progress occurred)375const resetProgressCodes = new Set([376 'ci_success',377 'fix_auto_applying',378 'fix_auto_apply_skipped',379 'fix_needs_review',380 'fix_apply_ready',381 'fix_needs_local_verify',382]);383 384function formatMessage(msg) {385 if (verbosity === 'minimal') {386 const currentStatus = `${cipeStatus}|${selfHealingStatus}|${verificationStatus}`;387 if (currentStatus === (prevStatus || '')) return null;388 return msg;389 }390 if (verbosity === 'verbose') {391 return [392 `Poll #${pollCount + 1} | CI: ${cipeStatus || 'N/A'} | Self-healing: ${393 selfHealingStatus || 'N/A'394 } | Verification: ${verificationStatus || 'N/A'}`,395 msg,396 ].join('\n');397 }398 return `Poll #${pollCount + 1} | ${msg}`;399}400 401function buildOutput(decision) {402 const { action, code, extra } = decision;403 404 // noProgressCount is already computed before classify() was called.405 // Here we only handle the reset for "genuine progress" done-codes.406 407 const msgFn = messages[code];408 const rawMsg = msgFn ? msgFn(extra) : `Unknown: ${code}`;409 const message = formatMessage(rawMsg);410 411 const result = {412 action,413 code,414 message,415 noProgressCount: resetProgressCodes.has(code) ? 0 : noProgressCount,416 envRerunCount,417 };418 419 // Add delay420 if (action === 'wait') {421 result.delay = 30;422 } else if (action === 'poll') {423 result.delay = code === 'new_cipe_detected' ? 60 : backoff(noProgressCount);424 result.fields = 'light';425 }426 427 // Add extras428 if (code === 'new_cipe_detected') result.newCipeDetected = true;429 if (extra?.verifiableTaskIds)430 result.verifiableTaskIds = extra.verifiableTaskIds;431 if (extra?.autoApplySkipReason)432 result.autoApplySkipReason = extra.autoApplySkipReason;433 434 console.log(JSON.stringify(result));435}436 437// --- Run ---438 439// Compute noProgressCount from input. Single assignment, no mutation.440// Wait mode: reset on new cipe, otherwise unchanged (wait doesn't count as no-progress).441// Normal mode: reset on any state change, otherwise increment.442const noProgressCount = (() => {443 if (waitMode) return isNewCipe() ? 0 : inputNoProgressCount;444 if (isNewCipe() || hasStateChanged()) return 0;445 return inputNoProgressCount + 1;446})();447 448buildOutput(classify());449