From e04216c51d1c664aaf42f3cfb93f1d8e2f65bb93 Mon Sep 17 00:00:00 2001 From: JorisJonkers Agent Date: Sun, 12 Jul 2026 12:19:22 +0000 Subject: [PATCH 1/3] feat(council): stall detection with liveness contract, watchdog decision logic, and poll-until-green helper Closes #18 --- council/prompts/worker.md | 9 + council/ts/src/workflows/index.ts | 2 + council/ts/src/workflows/supervise.test.ts | 308 +++++++++++++++++++++ council/ts/src/workflows/supervise.ts | 143 ++++++++++ council/ts/src/workflows/task-state.ts | 9 + 5 files changed, 471 insertions(+) create mode 100644 council/ts/src/workflows/supervise.test.ts create mode 100644 council/ts/src/workflows/supervise.ts create mode 100644 council/ts/src/workflows/task-state.ts diff --git a/council/prompts/worker.md b/council/prompts/worker.md index 797a544..4dfd896 100644 --- a/council/prompts/worker.md +++ b/council/prompts/worker.md @@ -36,3 +36,12 @@ for context, but only WRITE within the files listed above. End with a short plain-text summary: what you changed, in which files, and any caveat the orchestrator should know. This summary is read by the orchestrator, not a human — be terse and factual. + +## Liveness contract (mandatory) +Your final message MUST end with one of these exact status lines: + +`STATUS: DONE` — task is fully completed; no further action needed. + +`STATUS: WAITING(reason=, resume-condition=, deadline=)` — you are legitimately blocked waiting for an external event. Include the name of any active monitor: `monitor=`. + +Anything else (including trailing "waiting for CI..." text without the STATUS line) is treated as a **stall** by the orchestrator and will trigger an automatic nudge. diff --git a/council/ts/src/workflows/index.ts b/council/ts/src/workflows/index.ts index 6078a12..bce54ab 100644 --- a/council/ts/src/workflows/index.ts +++ b/council/ts/src/workflows/index.ts @@ -19,3 +19,5 @@ export * from './tail-workflow.js' export * from './triage.js' export * from './monitor.js' +export * from './supervise.js' +export * from './task-state.js' diff --git a/council/ts/src/workflows/supervise.test.ts b/council/ts/src/workflows/supervise.test.ts new file mode 100644 index 0000000..70f5409 --- /dev/null +++ b/council/ts/src/workflows/supervise.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it } from 'vitest' + +import { + buildPollUntilGreenMonitorArgs, + evaluateWatchdog, + parseWorkerLiveness, + shouldEscalate, +} from './supervise.js' +import type { + AutoNudgeConfig, + NudgeRecord, + PollUntilGreenInput, + WatchdogWorkerEntry, +} from './supervise.js' + +describe('parseWorkerLiveness', () => { + it('returns done when output contains STATUS: DONE', () => { + const result = parseWorkerLiveness('Some summary text.\nSTATUS: DONE') + expect(result.kind).toBe('done') + }) + + it('returns done when STATUS: DONE appears inline', () => { + const result = parseWorkerLiveness('STATUS: DONE') + expect(result.kind).toBe('done') + }) + + it('returns waiting with parsed fields for WAITING(...)', () => { + const output = + 'STATUS: WAITING(reason=CI not green, resume-condition=check actions runs, deadline=2026-07-12T10:00:00.000Z)' + const result = parseWorkerLiveness(output) + expect(result.kind).toBe('waiting') + expect(result.reason).toBe('CI not green') + expect(result.resumeCondition).toBe('check actions runs') + expect(result.deadline).toBe('2026-07-12T10:00:00.000Z') + }) + + it('returns waiting with monitor field when monitor= is present', () => { + const output = + 'STATUS: WAITING(reason=CI running, resume-condition=wait for green, deadline=2026-07-12T10:00:00.000Z, monitor=ci-sha-monitor)' + const result = parseWorkerLiveness(output) + expect(result.kind).toBe('waiting') + expect(result.monitor).toBe('ci-sha-monitor') + }) + + it('returns stalled for bare waiting text without STATUS line', () => { + const result = parseWorkerLiveness('Still waiting for CI to finish...') + expect(result.kind).toBe('stalled') + }) + + it('returns stalled for empty output', () => { + const result = parseWorkerLiveness('') + expect(result.kind).toBe('stalled') + }) + + it('returns stalled for generic summary text', () => { + const result = parseWorkerLiveness('I updated the file and now waiting for CI.') + expect(result.kind).toBe('stalled') + }) +}) + +describe('evaluateWatchdog', () => { + const baseTime = new Date('2026-07-12T10:00:00.000Z').getTime() + const stallWindowMs = 5 * 60 * 1_000 // 5 minutes + + it('returns fresh when last activity is within stall window', () => { + const entry: WatchdogWorkerEntry = { + taskId: 'task-1', + lastActivityAt: new Date(baseTime - 60_000).toISOString(), // 1 minute ago + } + const result = evaluateWatchdog(entry, baseTime, stallWindowMs, []) + expect(result.kind).toBe('fresh') + expect(result.taskId).toBe('task-1') + }) + + it('returns stalled when activity too old and no liveness report', () => { + const entry: WatchdogWorkerEntry = { + taskId: 'task-2', + lastActivityAt: new Date(baseTime - 10 * 60_000).toISOString(), // 10 minutes ago + } + const result = evaluateWatchdog(entry, baseTime, stallWindowMs, []) + expect(result.kind).toBe('stalled') + expect(result.taskId).toBe('task-2') + }) + + it('returns stalled when stale with no WAITING liveness', () => { + const entry: WatchdogWorkerEntry = { + taskId: 'task-3', + lastActivityAt: new Date(baseTime - 10 * 60_000).toISOString(), + livenessReport: { kind: 'stalled' }, + } + const result = evaluateWatchdog(entry, baseTime, stallWindowMs, []) + expect(result.kind).toBe('stalled') + }) + + it('returns waiting-with-live-monitor when WAITING and live monitor exists', () => { + const entry: WatchdogWorkerEntry = { + taskId: 'task-4', + lastActivityAt: new Date(baseTime - 10 * 60_000).toISOString(), + livenessReport: { + kind: 'waiting', + reason: 'CI running', + resumeCondition: 'check green', + deadline: '2026-07-12T12:00:00.000Z', + monitor: 'ci-monitor', + }, + monitorName: 'ci-monitor', + } + const monitors = [{ name: 'ci-monitor', status: 'polling', dead: false }] + const result = evaluateWatchdog(entry, baseTime, stallWindowMs, monitors) + expect(result.kind).toBe('waiting-with-live-monitor') + expect(result.taskId).toBe('task-4') + expect(result.reason).toBe('ci-monitor') + }) + + it('returns stale when WAITING but monitor is dead', () => { + const entry: WatchdogWorkerEntry = { + taskId: 'task-5', + lastActivityAt: new Date(baseTime - 10 * 60_000).toISOString(), + livenessReport: { + kind: 'waiting', + reason: 'CI running', + resumeCondition: 'check green', + deadline: '2026-07-12T12:00:00.000Z', + monitor: 'ci-monitor', + }, + monitorName: 'ci-monitor', + } + const monitors = [{ name: 'ci-monitor', status: 'polling', dead: true }] + const result = evaluateWatchdog(entry, baseTime, stallWindowMs, monitors) + expect(result.kind).toBe('stale') + expect(result.taskId).toBe('task-5') + }) + + it('returns stale when WAITING but monitor is not found', () => { + const entry: WatchdogWorkerEntry = { + taskId: 'task-6', + lastActivityAt: new Date(baseTime - 10 * 60_000).toISOString(), + livenessReport: { + kind: 'waiting', + reason: 'CI running', + resumeCondition: 'check green', + deadline: '2026-07-12T12:00:00.000Z', + monitor: 'ci-monitor', + }, + monitorName: 'ci-monitor', + } + const result = evaluateWatchdog(entry, baseTime, stallWindowMs, []) + expect(result.kind).toBe('stale') + expect(result.reason).toContain('not found') + }) + + it('returns stale when WAITING but monitor has timed-out status', () => { + const entry: WatchdogWorkerEntry = { + taskId: 'task-7', + lastActivityAt: new Date(baseTime - 10 * 60_000).toISOString(), + livenessReport: { + kind: 'waiting', + reason: 'CI running', + resumeCondition: 'check green', + deadline: '2026-07-12T12:00:00.000Z', + monitor: 'ci-monitor', + }, + monitorName: 'ci-monitor', + } + const monitors = [{ name: 'ci-monitor', status: 'timed-out', dead: false }] + const result = evaluateWatchdog(entry, baseTime, stallWindowMs, monitors) + expect(result.kind).toBe('stale') + }) + + it('uses monitor from livenessReport when entry.monitorName is absent', () => { + const entry: WatchdogWorkerEntry = { + taskId: 'task-8', + lastActivityAt: new Date(baseTime - 10 * 60_000).toISOString(), + livenessReport: { + kind: 'waiting', + reason: 'CI running', + resumeCondition: 'check green', + deadline: '2026-07-12T12:00:00.000Z', + monitor: 'inferred-monitor', + }, + } + const monitors = [{ name: 'inferred-monitor', status: 'polling', dead: false }] + const result = evaluateWatchdog(entry, baseTime, stallWindowMs, monitors) + expect(result.kind).toBe('waiting-with-live-monitor') + expect(result.reason).toBe('inferred-monitor') + }) +}) + +describe('shouldEscalate', () => { + const config: AutoNudgeConfig = { stallWindowMs: 5 * 60_000, maxNudges: 3 } + + it('returns false when nudgeCount is below maxNudges', () => { + const record: NudgeRecord = { + taskId: 'task-1', + nudgeCount: 2, + lastNudgeAt: '2026-07-12T10:00:00.000Z', + } + expect(shouldEscalate(record, config)).toBe(false) + }) + + it('returns true when nudgeCount equals maxNudges', () => { + const record: NudgeRecord = { + taskId: 'task-2', + nudgeCount: 3, + lastNudgeAt: '2026-07-12T10:00:00.000Z', + } + expect(shouldEscalate(record, config)).toBe(true) + }) + + it('returns true when nudgeCount exceeds maxNudges', () => { + const record: NudgeRecord = { + taskId: 'task-3', + nudgeCount: 5, + lastNudgeAt: '2026-07-12T10:00:00.000Z', + } + expect(shouldEscalate(record, config)).toBe(true) + }) + + it('returns false when nudgeCount is zero', () => { + const record: NudgeRecord = { + taskId: 'task-4', + nudgeCount: 0, + lastNudgeAt: '2026-07-12T10:00:00.000Z', + } + expect(shouldEscalate(record, config)).toBe(false) + }) +}) + +describe('buildPollUntilGreenMonitorArgs', () => { + it('produces correct argv with all required flags using defaults', () => { + const input: PollUntilGreenInput = { + sha: 'abc123', + repo: 'owner/repo', + monitorName: 'ci-green', + execDir: '/tmp/exec', + } + const args = buildPollUntilGreenMonitorArgs(input) + + expect(args[0]).toBe('start') + expect(args).toContain('--name') + expect(args[args.indexOf('--name') + 1]).toBe('ci-green') + expect(args).toContain('--interval') + expect(args[args.indexOf('--interval') + 1]).toBe('30s') + expect(args).toContain('--deadline') + expect(args[args.indexOf('--deadline') + 1]).toBe('45m') + expect(args).toContain('--cmd') + expect(args[args.indexOf('--cmd') + 1]).toContain('abc123') + expect(args[args.indexOf('--cmd') + 1]).toContain('owner/repo') + expect(args).toContain('--until') + expect(args[args.indexOf('--until') + 1]).toBe('.workflow_runs[0].conclusion == "success"') + expect(args).toContain('--then') + expect(args).toContain('--exec-dir') + expect(args[args.indexOf('--exec-dir') + 1]).toBe('/tmp/exec') + }) + + it('uses provided intervalMs and deadlineMs when set', () => { + const input: PollUntilGreenInput = { + sha: 'def456', + repo: 'org/proj', + monitorName: 'build-check', + execDir: '/tmp/exec', + intervalMs: 60_000, // 1m + deadlineMs: 7_200_000, // 2h + } + const args = buildPollUntilGreenMonitorArgs(input) + + expect(args[args.indexOf('--interval') + 1]).toBe('1m') + expect(args[args.indexOf('--deadline') + 1]).toBe('2h') + }) + + it('uses provided finalizer when set', () => { + const input: PollUntilGreenInput = { + sha: 'abc', + repo: 'owner/repo', + monitorName: 'mon', + execDir: '/tmp/exec', + finalizer: 'echo done', + } + const args = buildPollUntilGreenMonitorArgs(input) + + expect(args[args.indexOf('--then') + 1]).toBe('echo done') + }) + + it('uses empty string for then when finalizer is absent', () => { + const input: PollUntilGreenInput = { + sha: 'abc', + repo: 'owner/repo', + monitorName: 'mon', + execDir: '/tmp/exec', + } + const args = buildPollUntilGreenMonitorArgs(input) + + expect(args[args.indexOf('--then') + 1]).toBe('') + }) + + it('converts sub-minute intervalMs to seconds string', () => { + const input: PollUntilGreenInput = { + sha: 'abc', + repo: 'owner/repo', + monitorName: 'mon', + execDir: '/tmp/exec', + intervalMs: 30_000, // 30s + } + const args = buildPollUntilGreenMonitorArgs(input) + + expect(args[args.indexOf('--interval') + 1]).toBe('30s') + }) +}) diff --git a/council/ts/src/workflows/supervise.ts b/council/ts/src/workflows/supervise.ts new file mode 100644 index 0000000..a1c0a13 --- /dev/null +++ b/council/ts/src/workflows/supervise.ts @@ -0,0 +1,143 @@ +export interface WorkerLivenessReport { + readonly kind: 'done' | 'waiting' | 'stalled' + readonly reason?: string + readonly resumeCondition?: string + readonly deadline?: string // ISO8601 + readonly monitor?: string +} + +export function parseWorkerLiveness(output: string): WorkerLivenessReport { + if (/STATUS:\s*DONE/u.test(output)) { + return { kind: 'done' } + } + + const waitingMatch = + /STATUS:\s*WAITING\(reason=([^,)]+),\s*resume-condition=([^,)]+),\s*deadline=([^,)]+)(?:,\s*monitor=([^,)]+))?\)/u.exec( + output, + ) + + if (waitingMatch !== null) { + const reason = waitingMatch[1]?.trim() + const resumeCondition = waitingMatch[2]?.trim() + const deadline = waitingMatch[3]?.trim() + const monitor = waitingMatch[4]?.trim() + + return { + kind: 'waiting', + ...(reason !== undefined ? { reason } : {}), + ...(resumeCondition !== undefined ? { resumeCondition } : {}), + ...(deadline !== undefined ? { deadline } : {}), + ...(monitor !== undefined ? { monitor } : {}), + } + } + + return { kind: 'stalled' } +} + +export interface WatchdogWorkerEntry { + readonly taskId: string + readonly lastActivityAt: string // ISO8601 + readonly livenessReport?: WorkerLivenessReport + readonly monitorName?: string // if waiting, the monitor name being watched +} + +export interface WatchdogDecision { + readonly kind: 'fresh' | 'stale' | 'waiting-with-live-monitor' | 'stalled' + readonly taskId: string + readonly reason?: string +} + +export function evaluateWatchdog( + entry: WatchdogWorkerEntry, + nowMs: number, + stallWindowMs: number, + monitorList: readonly { readonly name: string; readonly status: string; readonly dead: boolean }[], +): WatchdogDecision { + const lastActivityMs = new Date(entry.lastActivityAt).getTime() + const elapsedMs = nowMs - lastActivityMs + + if (elapsedMs < stallWindowMs) { + return { kind: 'fresh', taskId: entry.taskId } + } + + const report = entry.livenessReport + const monitorName = entry.monitorName ?? report?.monitor + + if (report?.kind === 'waiting' && monitorName !== undefined) { + const monitor = monitorList.find((m) => m.name === monitorName) + + if (monitor !== undefined && !monitor.dead && monitor.status === 'polling') { + return { kind: 'waiting-with-live-monitor', taskId: entry.taskId, reason: monitorName } + } + + return { + kind: 'stale', + taskId: entry.taskId, + reason: + monitor === undefined + ? `monitor ${monitorName} not found` + : `monitor ${monitorName} is ${monitor.dead ? 'dead' : monitor.status}`, + } + } + + return { kind: 'stalled', taskId: entry.taskId } +} + +export interface AutoNudgeConfig { + readonly stallWindowMs: number + readonly maxNudges: number +} + +export interface NudgeRecord { + readonly taskId: string + readonly nudgeCount: number + readonly lastNudgeAt: string +} + +export function shouldEscalate(record: NudgeRecord, config: AutoNudgeConfig): boolean { + return record.nudgeCount >= config.maxNudges +} + +export interface PollUntilGreenInput { + readonly sha: string + readonly repo: string + readonly monitorName: string + readonly execDir: string + readonly intervalMs?: number + readonly deadlineMs?: number + readonly finalizer?: string +} + +function msToDurationString(ms: number): string { + const totalSeconds = Math.round(ms / 1_000) + const hours = Math.floor(totalSeconds / 3_600) + const minutes = Math.floor((totalSeconds % 3_600) / 60) + const seconds = totalSeconds % 60 + + if (hours > 0) return `${hours}h` + if (minutes > 0) return `${minutes}m` + return `${seconds}s` +} + +export function buildPollUntilGreenMonitorArgs(input: PollUntilGreenInput): string[] { + const interval = input.intervalMs !== undefined ? msToDurationString(input.intervalMs) : '30s' + const deadline = input.deadlineMs !== undefined ? msToDurationString(input.deadlineMs) : '45m' + + return [ + 'start', + '--name', + input.monitorName, + '--interval', + interval, + '--deadline', + deadline, + '--cmd', + `probe:actions-runs-for-sha --sha ${input.sha} --repo ${input.repo} --expected-status success`, + '--until', + '.workflow_runs[0].conclusion == "success"', + '--then', + input.finalizer ?? '', + '--exec-dir', + input.execDir, + ] +} diff --git a/council/ts/src/workflows/task-state.ts b/council/ts/src/workflows/task-state.ts new file mode 100644 index 0000000..62f7b97 --- /dev/null +++ b/council/ts/src/workflows/task-state.ts @@ -0,0 +1,9 @@ +export interface ResumeableTaskState { + readonly taskId: string + readonly stepsDone: readonly string[] + readonly nextStep: string + readonly watchedSha?: string + readonly watchedPr?: string + readonly monitorName?: string + readonly updatedAt: string // ISO8601 +} From d4e37a1c77bfdaec4da4552fb6158889ca562562 Mon Sep 17 00:00:00 2001 From: JorisJonkers Agent Date: Sun, 12 Jul 2026 12:22:03 +0000 Subject: [PATCH 2/3] chore: regenerate installer after worker.md liveness contract update --- installer/install.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/installer/install.sh b/installer/install.sh index 4d1b3c2..e24dcbd 100644 --- a/installer/install.sh +++ b/installer/install.sh @@ -3267,6 +3267,15 @@ for context, but only WRITE within the files listed above. End with a short plain-text summary: what you changed, in which files, and any caveat the orchestrator should know. This summary is read by the orchestrator, not a human — be terse and factual. + +## Liveness contract (mandatory) +Your final message MUST end with one of these exact status lines: + +`STATUS: DONE` — task is fully completed; no further action needed. + +`STATUS: WAITING(reason=, resume-condition=, deadline=)` — you are legitimately blocked waiting for an external event. Include the name of any active monitor: `monitor=`. + +Anything else (including trailing "waiting for CI..." text without the STATUS line) is treated as a **stall** by the orchestrator and will trigger an automatic nudge. COUNCIL_FILE_prompts_worker_md_EOF read -r -d '' COUNCIL_FILE_schemas_amendment_schema_json <<'COUNCIL_FILE_schemas_amendment_schema_json_EOF' || true { From cc00a62ca214264cf6f0d569a8be3e6cf6d42ae1 Mon Sep 17 00:00:00 2001 From: JorisJonkers Agent Date: Sun, 12 Jul 2026 12:26:59 +0000 Subject: [PATCH 3/3] fix(council): apply codex review findings to supervise and task-state - parseWorkerLiveness: enforce STATUS line at end of output, not anywhere (prevents early STATUS: DONE from masking trailing text) - evaluateWatchdog: treat expired WAITING deadline as stalled - buildPollUntilGreenMonitorArgs: validate sha/repo before interpolating into shell command string; fix --until predicate to use substring match compatible with evaluatePredicate - Rename ResumeableTaskState -> ResumableTaskState (typo fix) - Update tests to cover new behaviors --- council/ts/src/workflows/supervise.test.ts | 58 +++++++++++++++++++--- council/ts/src/workflows/supervise.ts | 20 ++++++-- council/ts/src/workflows/task-state.ts | 2 +- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/council/ts/src/workflows/supervise.test.ts b/council/ts/src/workflows/supervise.test.ts index 70f5409..7366b30 100644 --- a/council/ts/src/workflows/supervise.test.ts +++ b/council/ts/src/workflows/supervise.test.ts @@ -24,6 +24,11 @@ describe('parseWorkerLiveness', () => { expect(result.kind).toBe('done') }) + it('returns stalled when STATUS: DONE appears in the middle (not at end)', () => { + const result = parseWorkerLiveness('STATUS: DONE\nThen I kept going and did more things.') + expect(result.kind).toBe('stalled') + }) + it('returns waiting with parsed fields for WAITING(...)', () => { const output = 'STATUS: WAITING(reason=CI not green, resume-condition=check actions runs, deadline=2026-07-12T10:00:00.000Z)' @@ -167,6 +172,25 @@ describe('evaluateWatchdog', () => { expect(result.kind).toBe('stale') }) + it('returns stalled when WAITING but deadline has expired', () => { + const entry: WatchdogWorkerEntry = { + taskId: 'task-deadline', + lastActivityAt: new Date(baseTime - 10 * 60_000).toISOString(), + livenessReport: { + kind: 'waiting', + reason: 'CI running', + resumeCondition: 'check green', + deadline: '2026-07-12T09:00:00.000Z', // one hour BEFORE baseTime + monitor: 'ci-monitor', + }, + monitorName: 'ci-monitor', + } + const monitors = [{ name: 'ci-monitor', status: 'polling', dead: false }] + const result = evaluateWatchdog(entry, baseTime, stallWindowMs, monitors) + expect(result.kind).toBe('stalled') + expect(result.reason).toContain('deadline expired') + }) + it('uses monitor from livenessReport when entry.monitorName is absent', () => { const entry: WatchdogWorkerEntry = { taskId: 'task-8', @@ -229,7 +253,7 @@ describe('shouldEscalate', () => { describe('buildPollUntilGreenMonitorArgs', () => { it('produces correct argv with all required flags using defaults', () => { const input: PollUntilGreenInput = { - sha: 'abc123', + sha: 'abc1234', repo: 'owner/repo', monitorName: 'ci-green', execDir: '/tmp/exec', @@ -247,7 +271,7 @@ describe('buildPollUntilGreenMonitorArgs', () => { expect(args[args.indexOf('--cmd') + 1]).toContain('abc123') expect(args[args.indexOf('--cmd') + 1]).toContain('owner/repo') expect(args).toContain('--until') - expect(args[args.indexOf('--until') + 1]).toBe('.workflow_runs[0].conclusion == "success"') + expect(args[args.indexOf('--until') + 1]).toBe('"conclusion": "success"') expect(args).toContain('--then') expect(args).toContain('--exec-dir') expect(args[args.indexOf('--exec-dir') + 1]).toBe('/tmp/exec') @@ -255,7 +279,7 @@ describe('buildPollUntilGreenMonitorArgs', () => { it('uses provided intervalMs and deadlineMs when set', () => { const input: PollUntilGreenInput = { - sha: 'def456', + sha: 'def4567', repo: 'org/proj', monitorName: 'build-check', execDir: '/tmp/exec', @@ -270,7 +294,7 @@ describe('buildPollUntilGreenMonitorArgs', () => { it('uses provided finalizer when set', () => { const input: PollUntilGreenInput = { - sha: 'abc', + sha: 'abc1234', repo: 'owner/repo', monitorName: 'mon', execDir: '/tmp/exec', @@ -283,7 +307,7 @@ describe('buildPollUntilGreenMonitorArgs', () => { it('uses empty string for then when finalizer is absent', () => { const input: PollUntilGreenInput = { - sha: 'abc', + sha: 'abc1234', repo: 'owner/repo', monitorName: 'mon', execDir: '/tmp/exec', @@ -295,7 +319,7 @@ describe('buildPollUntilGreenMonitorArgs', () => { it('converts sub-minute intervalMs to seconds string', () => { const input: PollUntilGreenInput = { - sha: 'abc', + sha: 'abc1234', repo: 'owner/repo', monitorName: 'mon', execDir: '/tmp/exec', @@ -306,3 +330,25 @@ describe('buildPollUntilGreenMonitorArgs', () => { expect(args[args.indexOf('--interval') + 1]).toBe('30s') }) }) + +describe('buildPollUntilGreenMonitorArgs validation', () => { + it('throws on invalid SHA', () => { + const input: PollUntilGreenInput = { + sha: 'not-a-sha!', + repo: 'owner/repo', + monitorName: 'mon', + execDir: '/tmp/exec', + } + expect(() => buildPollUntilGreenMonitorArgs(input)).toThrow('invalid SHA') + }) + + it('throws on invalid repo format', () => { + const input: PollUntilGreenInput = { + sha: 'abc1234', + repo: 'invalid repo name', + monitorName: 'mon', + execDir: '/tmp/exec', + } + expect(() => buildPollUntilGreenMonitorArgs(input)).toThrow('invalid repo') + }) +}) diff --git a/council/ts/src/workflows/supervise.ts b/council/ts/src/workflows/supervise.ts index a1c0a13..caef51b 100644 --- a/council/ts/src/workflows/supervise.ts +++ b/council/ts/src/workflows/supervise.ts @@ -7,12 +7,12 @@ export interface WorkerLivenessReport { } export function parseWorkerLiveness(output: string): WorkerLivenessReport { - if (/STATUS:\s*DONE/u.test(output)) { + if (/STATUS:\s*DONE\s*$/u.test(output)) { return { kind: 'done' } } const waitingMatch = - /STATUS:\s*WAITING\(reason=([^,)]+),\s*resume-condition=([^,)]+),\s*deadline=([^,)]+)(?:,\s*monitor=([^,)]+))?\)/u.exec( + /STATUS:\s*WAITING\(reason=([^,)]+),\s*resume-condition=([^,)]+),\s*deadline=([^,)]+)(?:,\s*monitor=([^,)]+))?\)\s*$/u.exec( output, ) @@ -64,6 +64,10 @@ export function evaluateWatchdog( const monitorName = entry.monitorName ?? report?.monitor if (report?.kind === 'waiting' && monitorName !== undefined) { + if (report.deadline !== undefined && new Date(report.deadline).getTime() <= nowMs) { + return { kind: 'stalled', taskId: entry.taskId, reason: `deadline expired: ${report.deadline}` } + } + const monitor = monitorList.find((m) => m.name === monitorName) if (monitor !== undefined && !monitor.dead && monitor.status === 'polling') { @@ -108,6 +112,14 @@ export interface PollUntilGreenInput { readonly finalizer?: string } +function validateSha(sha: string): void { + if (!/^[0-9a-f]{7,40}$/iu.test(sha)) throw new Error(`invalid SHA: ${sha}`) +} + +function validateRepo(repo: string): void { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repo)) throw new Error(`invalid repo: ${repo}`) +} + function msToDurationString(ms: number): string { const totalSeconds = Math.round(ms / 1_000) const hours = Math.floor(totalSeconds / 3_600) @@ -120,6 +132,8 @@ function msToDurationString(ms: number): string { } export function buildPollUntilGreenMonitorArgs(input: PollUntilGreenInput): string[] { + validateSha(input.sha) + validateRepo(input.repo) const interval = input.intervalMs !== undefined ? msToDurationString(input.intervalMs) : '30s' const deadline = input.deadlineMs !== undefined ? msToDurationString(input.deadlineMs) : '45m' @@ -134,7 +148,7 @@ export function buildPollUntilGreenMonitorArgs(input: PollUntilGreenInput): stri '--cmd', `probe:actions-runs-for-sha --sha ${input.sha} --repo ${input.repo} --expected-status success`, '--until', - '.workflow_runs[0].conclusion == "success"', + '"conclusion": "success"', '--then', input.finalizer ?? '', '--exec-dir', diff --git a/council/ts/src/workflows/task-state.ts b/council/ts/src/workflows/task-state.ts index 62f7b97..e22710a 100644 --- a/council/ts/src/workflows/task-state.ts +++ b/council/ts/src/workflows/task-state.ts @@ -1,4 +1,4 @@ -export interface ResumeableTaskState { +export interface ResumableTaskState { readonly taskId: string readonly stepsDone: readonly string[] readonly nextStep: string