Files
Jyotisha/tools/agent-loop/grok-tick.ps1
T

178 lines
10 KiB
PowerShell

# One tick of the Grok side of the agent loop (Windows, PowerShell 5.1+).
# Picks at most one issue labelled agent/<todo> + agent/<scope>, runs Grok on
# the assigned codex/* branch, validates the result and hands over to Claude.
# This file is ASCII only: Windows PowerShell 5.1 reads BOM-less scripts as
# ANSI, so every Chinese string lives in grok-strings.json (read as UTF-8).
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version 2
$Bin = Split-Path -Parent $MyInvocation.MyCommand.Path
$Cfg = Get-Content -Raw -Encoding UTF8 (Join-Path $Bin 'grok-config.json') | ConvertFrom-Json
$S = [IO.File]::ReadAllText((Join-Path $Bin 'grok-strings.json'), [Text.Encoding]::UTF8) | ConvertFrom-Json
$ConfDir = Join-Path $HOME '.config\jyotisha-agent'
$StateDir = Join-Path $HOME '.jyotisha-agent\state'
$Api = "$($Cfg.gitea_url)/api/v1/repos/$($Cfg.gitea_repo)"
$Utf8 = New-Object Text.UTF8Encoding $false
New-Item -ItemType Directory -Force (Join-Path $StateDir 'runs') | Out-Null
$LogFile = Join-Path $StateDir 'grok-tick.log'
function Log($m) { [IO.File]::AppendAllText($LogFile, ("{0} {1}`r`n" -f (Get-Date).ToUniversalTime().ToString('s'), $m), $Utf8) }
if (Test-Path (Join-Path $ConfDir 'STOP')) { Log 'STOP file present, skipping'; exit 0 }
$mutex = New-Object Threading.Mutex($false, 'Local\JyotishaAgentGrokTick')
if (-not $mutex.WaitOne(0)) { Log 'another tick is running, skipping'; exit 0 }
$Token = ([IO.File]::ReadAllText((Join-Path $ConfDir 'gitea-token'))).Trim()
function Gitea([string]$Method, [string]$Path, $Body = $null) {
$p = @{ Uri = "$Api$Path"; Method = $Method; UseBasicParsing = $true
Headers = @{ Authorization = "token $Token" } }
if ($null -ne $Body) {
$p.Body = $Utf8.GetBytes(($Body | ConvertTo-Json -Depth 10 -Compress))
$p.ContentType = 'application/json; charset=utf-8'
}
$r = Invoke-WebRequest @p
$text = $Utf8.GetString($r.RawContentStream.ToArray())
if ($text) { return $text | ConvertFrom-Json } else { return $null }
}
function Enc($s) { [uri]::EscapeDataString($s) }
function Issues($labels) { @(Gitea GET "/issues?state=open&type=issues&limit=50&labels=$(Enc ($labels -join ','))") | Sort-Object created_at }
function LabelNames($n) { @(Gitea GET "/issues/$n/labels" | ForEach-Object { $_.name }) }
function LabelId($name) { (@(Gitea GET '/labels?limit=100') | Where-Object { $_.name -eq $name } | Select-Object -First 1).id }
function AddLabel($n, $name) { Gitea POST "/issues/$n/labels" @{ labels = @($name) } | Out-Null }
function RemoveLabel($n, $name) {
if ((LabelNames $n) -contains $name) { Gitea DELETE "/issues/$n/labels/$(LabelId $name)" | Out-Null }
}
function SetState($n, $new) {
foreach ($st in $S.states) { if ($st -ne $new) { RemoveLabel $n $st } }
AddLabel $n $new
}
$Owner = (Invoke-RestMethod -UseBasicParsing -Uri "$($Cfg.gitea_url)/api/v1/user" -Headers @{ Authorization = "token $Token" }).login
function OwnerComments($n) { @(Gitea GET "/issues/$n/comments" | Where-Object { $_.user.login -eq $Owner }) }
function Markers($n) { @(OwnerComments $n | ForEach-Object { ($_.body -split "`n")[0] } | Where-Object { $_ -like '<!-- agent:*' }) }
function Field($marker, $name) { if ($marker -match " $name=([^ ]+)") { $Matches[1] } else { '' } }
function Post($n, $marker, $text) {
$body = "$marker`n$text`n`n" + ($S.footer -f $RunId)
Gitea POST "/issues/$n/comments" @{ body = $body } | Out-Null
}
function ErrorsSinceProgress($n) {
$c = 0
$m = @(Markers $n | Where-Object { $_ -like '<!-- agent:grok*' })
for ($i = $m.Count - 1; $i -ge 0; $i--) { if ($m[$i] -like '<!-- agent:grok error*') { $c++ } else { break } }
return $c
}
function FailRun($n, $reason) {
Post $n '<!-- agent:grok error -->' ($S.msg_error -f $reason)
$e = ErrorsSinceProgress $n
if ($e -ge 3) {
SetState $n $S.label_decision
Post $n '<!-- agent:grok escalate -->' ($S.msg_escalate -f $Owner, $e, $reason, $LogFile)
}
Log "issue #${n}: error: $reason"
}
function PrivacyOk($text) {
$pat = '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}|-----BEGIN [A-Z ]*PRIVATE KEY|(api[_-]?key|secret|password|passwd|token)\s*[:=]\s*[^\s<]{8,}'
return -not ($text -match $pat)
}
$RunId = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
try {
# 1. Stale lock: the mutex proves no other tick runs, so a lock label is a crash leftover.
foreach ($i in Issues @($S.label_lock, $S.label_scope)) {
RemoveLabel $i.number $S.label_lock
FailRun $i.number $S.msg_stale
}
# 2. Pick the oldest todo issue.
$todo = @(Issues @($S.label_todo, $S.label_scope) | Where-Object { @($_.labels | ForEach-Object { $_.name }) -notcontains $S.label_lock })
if ($todo.Count -eq 0) { Log 'nothing to do'; exit 0 }
$n = $todo[0].number
$plan = @(Markers $n | Where-Object { $_ -like '<!-- agent:claude plan*' -or ($_ -like '<!-- agent:claude review*' -and $_ -like '*verdict=fail*') }) | Select-Object -Last 1
if (-not $plan) { FailRun $n $S.msg_no_plan; exit 0 }
$branch = Field $plan 'branch'; $brief = Field $plan 'brief'
if ($branch -notmatch '^codex/[a-z0-9][a-z0-9-]*-[0-9]{8}$' -or -not $brief) { FailRun $n $S.msg_no_plan; exit 0 }
$RunDir = Join-Path $StateDir "runs\$RunId"
New-Item -ItemType Directory -Force $RunDir | Out-Null
Log "issue #${n}: execute $branch ($RunId)"
AddLabel $n $S.label_lock
# 3. Worktree: dedicated to the loop; ignored deps (node_modules, .venv) survive the clean.
$env:PATH = "$($Cfg.git_bash_dir);$env:PATH"
$env:GIT_CONFIG_COUNT = '1'; $env:GIT_CONFIG_KEY_0 = 'core.hooksPath'; $env:GIT_CONFIG_VALUE_0 = (Join-Path $Bin 'hooks\grok')
$wt = $Cfg.worktree
try {
git -C $Cfg.repo fetch -q origin --prune; if ($LASTEXITCODE) { throw 'git fetch' }
$remoteHead = (git -C $Cfg.repo ls-remote origin "refs/heads/$branch") -split "`t" | Select-Object -First 1
$base = if ($remoteHead) { "origin/$branch" } else { 'origin/staging' }
git -C $wt checkout -q -f -B $branch $base; if ($LASTEXITCODE) { throw "checkout $base" }
git -C $wt clean -q -fd; if ($LASTEXITCODE) { throw 'git clean' }
foreach ($dep in @(
@{ file = 'frontend\package-lock.json'; stamp = 'npm.stamp'; cmd = { Push-Location (Join-Path $wt 'frontend'); npm ci --no-audit --no-fund | Out-Null; $code = $LASTEXITCODE; Pop-Location; $code } },
@{ file = 'requirements.txt'; stamp = 'pip.stamp'; cmd = { & (Join-Path $wt '.venv\Scripts\pip.exe') install -q -r (Join-Path $wt 'requirements.txt') 'pytest>=8.0' 'hypothesis>=6.0' | Out-Null; $LASTEXITCODE } })) {
$hash = (Get-FileHash (Join-Path $wt $dep.file)).Hash
$stampFile = Join-Path $StateDir $dep.stamp
if (-not (Test-Path $stampFile) -or (Get-Content $stampFile) -ne $hash) {
if (& $dep.cmd) { throw "install $($dep.file)" }
Set-Content $stampFile $hash
}
}
} catch { RemoveLabel $n $S.label_lock; FailRun $n ($S.msg_worktree -f $_); exit 0 }
# 4. Context and prompt.
docker info *> $null; $dockerUp = ($LASTEXITCODE -eq 0)
$issue = Gitea GET "/issues/$n"
$failed = @(Markers $n | Where-Object { $_ -like '<!-- agent:claude review*verdict=fail*' }).Count
$resultPath = Join-Path $RunDir 'result.json'
$ctx = [ordered]@{
branch = $branch; brief_path = $brief; docker_up = $dockerUp; previous_failed_reviews = $failed
result_path = $resultPath
issue = [ordered]@{ number = $n; title = $issue.title; body = $(if ($issue.user.login -eq $Owner) { $issue.body } else { '' }) }
comments = @(OwnerComments $n | ForEach-Object { [ordered]@{ created_at = $_.created_at; body = $_.body } })
}
$ctxPath = Join-Path $RunDir 'context.json'
[IO.File]::WriteAllText($ctxPath, ($ctx | ConvertTo-Json -Depth 10), $Utf8)
$promptPath = Join-Path $RunDir 'prompt.md'
$prompt = [IO.File]::ReadAllText((Join-Path $Bin 'prompts\grok-execute.md'), [Text.Encoding]::UTF8)
[IO.File]::WriteAllText($promptPath, "$prompt`n`nContext file (JSON): $ctxPath`n", $Utf8)
# 5. Run Grok with the allowlist from grok-permissions.json, under a hard timeout.
$perm = Get-Content -Raw -Encoding UTF8 (Join-Path $Bin 'grok-permissions.json') | ConvertFrom-Json
# ($args is an automatic variable in PowerShell, hence $grokArgs.)
$grokArgs = @('-p', '--prompt-file', $promptPath, '--cwd', $wt, '--output-format', 'json',
'--max-turns', "$($Cfg.max_turns)", '--permission-mode', $perm.mode)
foreach ($a in $perm.allow) { $grokArgs += @('--allow', $a) }
foreach ($d in $perm.deny) { $grokArgs += @('--deny', $d) }
# Start-Process joins arguments with spaces unquoted, so quote each one ourselves.
$argLine = ($grokArgs | ForEach-Object { '"' + ($_ -replace '"', '\"') + '"' }) -join ' '
$proc = Start-Process -FilePath $Cfg.grok_exe -ArgumentList $argLine -NoNewWindow -PassThru `
-RedirectStandardOutput (Join-Path $RunDir 'out.json') -RedirectStandardError (Join-Path $RunDir 'err.txt')
if (-not $proc.WaitForExit([int]$Cfg.timeout_minutes * 60000)) {
taskkill /T /F /PID $proc.Id | Out-Null
RemoveLabel $n $S.label_lock; FailRun $n ($S.msg_timeout -f $Cfg.timeout_minutes); exit 0
}
# 6. Validate and hand over.
if (-not (Test-Path $resultPath)) { RemoveLabel $n $S.label_lock; FailRun $n ($S.msg_no_result -f $resultPath); exit 0 }
$res = [IO.File]::ReadAllText($resultPath, [Text.Encoding]::UTF8) | ConvertFrom-Json
if (-not (PrivacyOk $res.comment_markdown)) { RemoveLabel $n $S.label_lock; FailRun $n ($S.msg_privacy -f $RunDir); exit 0 }
if ($res.next -eq $S.next_review) {
if ($res.branch -ne $branch) { RemoveLabel $n $S.label_lock; FailRun $n ($S.msg_bad_branch -f $res.branch, $branch); exit 0 }
$head = (git -C $Cfg.repo ls-remote origin "refs/heads/$branch") -split "`t" | Select-Object -First 1
if (-not $head -or $res.head_sha -ne $head) { RemoveLabel $n $S.label_lock; FailRun $n ($S.msg_sha_mismatch -f $res.head_sha, $head); exit 0 }
Post $n "<!-- agent:grok report branch=$branch sha=$head -->" $res.comment_markdown
SetState $n $S.label_review
} elseif ($res.next -eq $S.next_decision) {
Post $n '<!-- agent:grok decision -->' $res.comment_markdown
SetState $n $S.label_decision
} else {
RemoveLabel $n $S.label_lock; FailRun $n ($S.msg_bad_next -f $res.next); exit 0
}
RemoveLabel $n $S.label_lock
Log "issue #${n}: done -> $($res.next)"
} catch {
Log "tick crashed: $_"
throw
} finally {
$mutex.ReleaseMutex()
}