fix: Codex compaction returns "Model not found" for gpt-5.6-luna #383
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Runs the repo's /is prompt against an issue when the `pi-analyze` label is added | |
| # or when a staff member comments `@issuron analyze` anywhere on an issue. | |
| # | |
| # Comment triggers can include one `#run-on-*` tag anywhere in the text: | |
| # @issuron analyze #run-on-linux -> ubuntu-latest (default) | |
| # @issuron analyze #run-on-windows -> windows-latest | |
| # @issuron analyze #run-on-mac -> macos-latest | |
| # | |
| # Label triggers always run on the default Linux runner. Runner selection is | |
| # intentionally restricted to hardcoded aliases in the authorization step. | |
| # | |
| # Setup required before this works: | |
| # 1. Create a `pi-analyze` GitHub environment on the repo and add a | |
| # `PI_AUTH_JSON` secret containing the contents of a pi auth.json | |
| # (~/.pi/agent/auth.json). | |
| # 2. Create the `pi-analyze` label. | |
| # 3. Add a repository secret `EARENDIL_ORG_READ_TOKEN` with permission to | |
| # read `earendil-works` org membership. The authorization job uses it to | |
| # verify that the label actor is an active member of `earendil-works/staff`. | |
| # 4. Add an environment secret `PI_GIST_TOKEN` on `pi-analyze` with gist | |
| # creation permission. The analysis job uses it to upload the exported | |
| # session gist. | |
| # 5. Add an environment secret `PI_AUTH_UPDATE_TOKEN` on `pi-analyze` with | |
| # permission to update this repo's environment secrets. The analysis job | |
| # uses it to write back refreshed `PI_AUTH_JSON` contents. | |
| # | |
| # The selected runner must have Node.js support plus gh, fd, and ripgrep. GitHub | |
| # hosted runners are bootstrapped below; future self-hosted aliases should have | |
| # those dependencies preinstalled or installable by the setup steps. | |
| # | |
| # The session runs in a high-entropy checkout directory so the recorded cwd is | |
| # a unique string. Import the session into a local checkout with the | |
| # /ir extension command (.pi/extensions/import-repro.ts): | |
| # pi "/ir <gist-id | gist-url | pi.dev/session URL>" | |
| name: Issue Analysis | |
| on: | |
| issues: | |
| types: [labeled] | |
| issue_comment: | |
| types: [created] | |
| permissions: | |
| contents: read | |
| issues: write | |
| concurrency: | |
| group: issue-analysis-${{ github.event.issue.number }} | |
| cancel-in-progress: false | |
| jobs: | |
| authorize: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| should_run: ${{ steps.verify.outputs.should_run }} | |
| extra_instructions: ${{ steps.verify.outputs.extra_instructions }} | |
| runs_on: ${{ steps.verify.outputs.runs_on }} | |
| runner_os: ${{ steps.verify.outputs.runner_os }} | |
| runner_profile: ${{ steps.verify.outputs.runner_profile }} | |
| steps: | |
| - name: Verify sender permission | |
| id: verify | |
| uses: actions/github-script@v7 | |
| env: | |
| ORG_READ_TOKEN: ${{ secrets.EARENDIL_ORG_READ_TOKEN }} | |
| with: | |
| script: | | |
| const ANALYZE_LABEL = 'pi-analyze'; | |
| const TRIGGER_RE = /@issuron\s+analyze\b/i; | |
| const RUN_ON_TAG_RE = /#run-on-([a-z0-9][a-z0-9_-]*)\b/gi; | |
| const RUNNER_PROFILES = { | |
| linux: { runsOn: 'ubuntu-latest', os: 'linux' }, | |
| windows: { runsOn: 'windows-latest', os: 'windows' }, | |
| mac: { runsOn: 'macos-latest', os: 'macos' }, | |
| }; | |
| const RUN_ON_ALIASES = { | |
| linux: 'linux', | |
| ubuntu: 'linux', | |
| 'ubuntu-latest': 'linux', | |
| windows: 'windows', | |
| win: 'windows', | |
| 'windows-latest': 'windows', | |
| mac: 'mac', | |
| macos: 'mac', | |
| darwin: 'mac', | |
| 'macos-latest': 'mac', | |
| }; | |
| const username = context.payload.sender.login; | |
| let extraInstructions = ''; | |
| let runnerProfile = 'linux'; | |
| core.setOutput('should_run', 'false'); | |
| core.setOutput('extra_instructions', ''); | |
| core.setOutput('runs_on', JSON.stringify(RUNNER_PROFILES.linux.runsOn)); | |
| core.setOutput('runner_os', RUNNER_PROFILES.linux.os); | |
| core.setOutput('runner_profile', runnerProfile); | |
| if (context.eventName === 'issues') { | |
| if (context.payload.action !== 'labeled' || context.payload.label?.name !== ANALYZE_LABEL) { | |
| console.log('Not a pi-analyze label event'); | |
| return; | |
| } | |
| } else if (context.eventName === 'issue_comment') { | |
| if (context.payload.issue.pull_request) { | |
| console.log('Ignoring pull request comment'); | |
| return; | |
| } | |
| const body = context.payload.comment.body || ''; | |
| if (!TRIGGER_RE.test(body)) { | |
| console.log('Comment does not contain an @issuron analyze trigger'); | |
| return; | |
| } | |
| const resolvedProfiles = new Set(); | |
| const unknownTags = []; | |
| for (const match of body.matchAll(RUN_ON_TAG_RE)) { | |
| const tag = match[1].toLowerCase(); | |
| const resolved = RUN_ON_ALIASES[tag]; | |
| if (!resolved) { | |
| unknownTags.push(tag); | |
| } else { | |
| resolvedProfiles.add(resolved); | |
| } | |
| } | |
| if (unknownTags.length > 0) { | |
| core.setFailed(`Unknown issue analysis runner tag(s): ${unknownTags.map((tag) => `#run-on-${tag}`).join(', ')}`); | |
| return; | |
| } | |
| if (resolvedProfiles.size > 1) { | |
| core.setFailed( | |
| `Conflicting issue analysis runner tags: ${Array.from(resolvedProfiles) | |
| .map((profile) => `#run-on-${profile}`) | |
| .join(', ')}`, | |
| ); | |
| return; | |
| } | |
| runnerProfile = Array.from(resolvedProfiles)[0] || 'linux'; | |
| extraInstructions = body.replace(TRIGGER_RE, ' ').replace(RUN_ON_TAG_RE, ' ').trim(); | |
| } else { | |
| console.log(`Unsupported event: ${context.eventName}`); | |
| return; | |
| } | |
| async function removeTriggerLabel() { | |
| if (context.eventName !== 'issues') return; | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| name: ANALYZE_LABEL, | |
| }); | |
| } catch (error) { | |
| if (error.status !== 404) throw error; | |
| } | |
| } | |
| if (!process.env.ORG_READ_TOKEN) { | |
| await removeTriggerLabel(); | |
| core.setFailed('EARENDIL_ORG_READ_TOKEN is not configured; refusing to run issue analysis.'); | |
| return; | |
| } | |
| try { | |
| const response = await fetch( | |
| `https://api.github.com/orgs/earendil-works/teams/staff/memberships/${encodeURIComponent(username)}`, | |
| { | |
| headers: { | |
| Accept: 'application/vnd.github+json', | |
| Authorization: `Bearer ${process.env.ORG_READ_TOKEN}`, | |
| 'X-GitHub-Api-Version': '2022-11-28', | |
| }, | |
| }, | |
| ); | |
| if (response.status === 404) { | |
| await removeTriggerLabel(); | |
| core.setFailed(`@${username} is not an active earendil-works/staff member.`); | |
| return; | |
| } | |
| if (!response.ok) { | |
| const body = await response.text(); | |
| await removeTriggerLabel(); | |
| core.setFailed( | |
| `Could not verify earendil-works/staff membership for @${username}: HTTP ${response.status} ${body}`, | |
| ); | |
| return; | |
| } | |
| const membership = await response.json(); | |
| if (membership.state !== 'active') { | |
| await removeTriggerLabel(); | |
| core.setFailed(`@${username} is not an active earendil-works/staff member.`); | |
| return; | |
| } | |
| console.log(`earendil-works/staff membership for @${username}: ${membership.state}`); | |
| } catch (error) { | |
| await removeTriggerLabel(); | |
| core.setFailed( | |
| `Could not verify earendil-works/staff membership for @${username}: ${ | |
| error instanceof Error ? error.message : String(error) | |
| }`, | |
| ); | |
| return; | |
| } | |
| const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| username, | |
| }); | |
| if (!['admin', 'write'].includes(data.permission)) { | |
| await removeTriggerLabel(); | |
| core.setFailed( | |
| `@${username} has '${data.permission}' permission; write or admin is required to trigger issue analysis.`, | |
| ); | |
| return; | |
| } | |
| if (context.eventName === 'issue_comment') { | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| labels: [ANALYZE_LABEL], | |
| }); | |
| } | |
| const profile = RUNNER_PROFILES[runnerProfile]; | |
| console.log(`Selected issue analysis runner profile: ${runnerProfile} (${JSON.stringify(profile.runsOn)})`); | |
| core.setOutput('should_run', 'true'); | |
| core.setOutput('extra_instructions', extraInstructions); | |
| core.setOutput('runs_on', JSON.stringify(profile.runsOn)); | |
| core.setOutput('runner_os', profile.os); | |
| core.setOutput('runner_profile', runnerProfile); | |
| analyze: | |
| needs: authorize | |
| if: needs.authorize.outputs.should_run == 'true' | |
| runs-on: ${{ fromJSON(needs.authorize.outputs.runs_on) }} | |
| environment: pi-analyze | |
| timeout-minutes: 45 | |
| concurrency: | |
| group: issue-analysis-pi-auth | |
| cancel-in-progress: false | |
| env: | |
| ISSUE_ANALYSIS_MODEL: openai-codex/gpt-5.5 | |
| ISSUE_ANALYSIS_THINKING: high | |
| steps: | |
| - name: Create high-entropy working directory name | |
| id: workdir | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const crypto = require('crypto'); | |
| core.setOutput('name', `pi-ci-${crypto.randomBytes(16).toString('hex')}`); | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| path: ${{ steps.workdir.outputs.name }} | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| cache-dependency-path: ${{ steps.workdir.outputs.name }}/package-lock.json | |
| - name: Install system dependencies (Linux) | |
| if: needs.authorize.outputs.runner_os == 'linux' | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y fd-find ripgrep | |
| sudo ln -sf "$(which fdfind)" /usr/local/bin/fd | |
| - name: Install system dependencies (macOS) | |
| if: needs.authorize.outputs.runner_os == 'macos' | |
| run: | | |
| if ! command -v fd >/dev/null 2>&1; then | |
| brew install fd | |
| fi | |
| if ! command -v rg >/dev/null 2>&1; then | |
| brew install ripgrep | |
| fi | |
| - name: Install system dependencies (Windows) | |
| if: needs.authorize.outputs.runner_os == 'windows' | |
| shell: pwsh | |
| run: | | |
| $packages = @() | |
| if (-not (Get-Command fd -ErrorAction SilentlyContinue)) { | |
| $packages += "fd" | |
| } | |
| if (-not (Get-Command rg -ErrorAction SilentlyContinue)) { | |
| $packages += "ripgrep" | |
| } | |
| if ($packages.Count -gt 0) { | |
| if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { | |
| throw "fd and ripgrep must be installed on Windows runners, or Chocolatey must be available to install them." | |
| } | |
| choco install $packages -y --no-progress | |
| } | |
| fd --version | |
| rg --version | |
| - name: Install dependencies | |
| working-directory: ${{ steps.workdir.outputs.name }} | |
| run: npm ci --ignore-scripts | |
| - name: Build | |
| working-directory: ${{ steps.workdir.outputs.name }} | |
| run: npm run build | |
| - name: Write auth.json | |
| id: write_auth | |
| uses: actions/github-script@v7 | |
| env: | |
| PI_AUTH_JSON: ${{ secrets.PI_AUTH_JSON }} | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const authJson = process.env.PI_AUTH_JSON; | |
| if (!authJson) { | |
| throw new Error('PI_AUTH_JSON secret is not configured for the pi-analyze environment'); | |
| } | |
| const agentDir = path.join(process.env.RUNNER_TEMP, 'pi-agent'); | |
| fs.mkdirSync(agentDir, { recursive: true }); | |
| const authPath = path.join(agentDir, 'auth.json'); | |
| fs.writeFileSync(authPath, authJson, { mode: 0o600 }); | |
| if (process.platform !== 'win32') { | |
| fs.chmodSync(authPath, 0o600); | |
| } | |
| - name: Run pi /is | |
| uses: actions/github-script@v7 | |
| env: | |
| PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent | |
| GH_TOKEN: ${{ github.token }} | |
| ISSUE_URL: ${{ github.event.issue.html_url }} | |
| EXTRA_INSTRUCTIONS: ${{ needs.authorize.outputs.extra_instructions }} | |
| WORKDIR: ${{ steps.workdir.outputs.name }} | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const { spawn } = require('child_process'); | |
| const workdir = path.join(process.env.GITHUB_WORKSPACE, process.env.WORKDIR); | |
| const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out'); | |
| const sessionDir = path.join(outDir, 'session'); | |
| fs.mkdirSync(sessionDir, { recursive: true }); | |
| let prompt = `/is ${process.env.ISSUE_URL}`; | |
| if (process.env.EXTRA_INSTRUCTIONS) { | |
| prompt += '\n\nAdditional instructions from @issuron analyze comment:\n'; | |
| prompt += process.env.EXTRA_INSTRUCTIONS; | |
| } | |
| const outputPath = path.join(outDir, 'output.md'); | |
| const output = fs.createWriteStream(outputPath); | |
| const args = [ | |
| 'packages/coding-agent/src/cli.ts', | |
| '-p', | |
| '--approve', | |
| '--session-dir', | |
| sessionDir, | |
| '--model', | |
| process.env.ISSUE_ANALYSIS_MODEL, | |
| '--thinking', | |
| process.env.ISSUE_ANALYSIS_THINKING, | |
| prompt, | |
| ]; | |
| const exitCode = await new Promise((resolve, reject) => { | |
| const child = spawn('node', args, { | |
| cwd: workdir, | |
| env: process.env, | |
| stdio: ['ignore', 'pipe', 'pipe'], | |
| }); | |
| child.stdout.on('data', (chunk) => { | |
| process.stdout.write(chunk); | |
| output.write(chunk); | |
| }); | |
| child.stderr.on('data', (chunk) => { | |
| process.stderr.write(chunk); | |
| }); | |
| child.on('error', reject); | |
| child.on('close', resolve); | |
| }); | |
| await new Promise((resolve) => output.end(resolve)); | |
| if (exitCode !== 0) { | |
| throw new Error(`pi /is failed with exit code ${exitCode}`); | |
| } | |
| - name: Persist refreshed auth.json | |
| if: always() && steps.write_auth.outcome == 'success' | |
| uses: actions/github-script@v7 | |
| env: | |
| GH_TOKEN: ${{ secrets.PI_AUTH_UPDATE_TOKEN }} | |
| PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const { spawn } = require('child_process'); | |
| if (!process.env.GH_TOKEN) { | |
| throw new Error('PI_AUTH_UPDATE_TOKEN is not configured for the pi-analyze environment'); | |
| } | |
| const authPath = path.join(process.env.PI_CODING_AGENT_DIR, 'auth.json'); | |
| if (!fs.existsSync(authPath)) { | |
| core.warning('auth.json was not created; skipping auth persistence'); | |
| return; | |
| } | |
| const authJson = fs.readFileSync(authPath, 'utf8'); | |
| let parsed; | |
| try { | |
| parsed = JSON.parse(authJson); | |
| } catch (error) { | |
| throw new Error(`Refusing to persist malformed auth.json: ${error instanceof Error ? error.message : String(error)}`); | |
| } | |
| const codexAuth = parsed['openai-codex']; | |
| if (codexAuth?.type !== 'oauth' || typeof codexAuth.refresh !== 'string' || codexAuth.refresh.length === 0) { | |
| throw new Error('Refusing to persist auth.json without openai-codex OAuth refresh credentials'); | |
| } | |
| await new Promise((resolve, reject) => { | |
| const child = spawn( | |
| 'gh', | |
| ['secret', 'set', 'PI_AUTH_JSON', '--env', 'pi-analyze', '--repo', process.env.GITHUB_REPOSITORY], | |
| { env: process.env, stdio: ['pipe', 'inherit', 'inherit'] }, | |
| ); | |
| child.stdin.end(authJson); | |
| child.on('error', reject); | |
| child.on('close', (code) => { | |
| if (code === 0) { | |
| resolve(); | |
| } else { | |
| reject(new Error(`gh secret set failed with exit code ${code}`)); | |
| } | |
| }); | |
| }); | |
| - name: Export session files | |
| id: export_session_files | |
| if: always() | |
| uses: actions/github-script@v7 | |
| env: | |
| PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent | |
| WORKDIR: ${{ steps.workdir.outputs.name }} | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const { spawn } = require('child_process'); | |
| function findFirstJsonl(dir) { | |
| if (!fs.existsSync(dir)) return undefined; | |
| const entries = fs.readdirSync(dir, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| const entryPath = path.join(dir, entry.name); | |
| if (entry.isDirectory()) { | |
| const nested = findFirstJsonl(entryPath); | |
| if (nested) return nested; | |
| } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { | |
| return entryPath; | |
| } | |
| } | |
| return undefined; | |
| } | |
| const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out'); | |
| const sessionFile = findFirstJsonl(path.join(outDir, 'session')); | |
| if (!sessionFile) { | |
| throw new Error('No session jsonl file found'); | |
| } | |
| const sessionJsonl = path.join(outDir, 'session.jsonl'); | |
| const sessionHtml = path.join(outDir, 'session.html'); | |
| fs.copyFileSync(sessionFile, sessionJsonl); | |
| const workdir = path.join(process.env.GITHUB_WORKSPACE, process.env.WORKDIR); | |
| const exitCode = await new Promise((resolve, reject) => { | |
| const child = spawn( | |
| 'node', | |
| ['packages/coding-agent/src/cli.ts', '--no-extensions', '--export', sessionJsonl, sessionHtml], | |
| { cwd: workdir, env: process.env, stdio: 'inherit' }, | |
| ); | |
| child.on('error', reject); | |
| child.on('close', resolve); | |
| }); | |
| if (exitCode !== 0) { | |
| throw new Error(`session export failed with exit code ${exitCode}`); | |
| } | |
| - name: Upload session gist | |
| id: gist | |
| if: always() && steps.export_session_files.outcome == 'success' | |
| uses: actions/github-script@v7 | |
| env: | |
| PI_GIST_TOKEN: ${{ secrets.PI_GIST_TOKEN }} | |
| with: | |
| github-token: ${{ secrets.PI_GIST_TOKEN }} | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| if (!process.env.PI_GIST_TOKEN) { | |
| throw new Error('PI_GIST_TOKEN is not configured'); | |
| } | |
| const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out'); | |
| const files = {}; | |
| for (const filename of ['session.html', 'session.jsonl']) { | |
| files[filename] = { content: fs.readFileSync(path.join(outDir, filename), 'utf8') }; | |
| } | |
| const response = await github.rest.gists.create({ | |
| public: false, | |
| files, | |
| }); | |
| const gistUrl = response.data.html_url; | |
| const gistId = response.data.id; | |
| core.setOutput('url', gistUrl); | |
| core.setOutput('id', gistId); | |
| core.setOutput('share_url', `https://pi.dev/session/#${gistId}`); | |
| - name: Comment with session import instructions | |
| if: always() && steps.gist.outcome == 'success' | |
| uses: actions/github-script@v7 | |
| env: | |
| GIST_URL: ${{ steps.gist.outputs.url }} | |
| GIST_ID: ${{ steps.gist.outputs.id }} | |
| SHARE_URL: ${{ steps.gist.outputs.share_url }} | |
| SESSION_JSONL: ${{ runner.temp }}/pi-out/session.jsonl | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| function extractLastAgentMessage(sessionPath) { | |
| const lines = fs.readFileSync(sessionPath, 'utf8').split(/\r?\n/).filter(Boolean); | |
| let lastText = ''; | |
| for (const line of lines) { | |
| let entry; | |
| try { | |
| entry = JSON.parse(line); | |
| } catch { | |
| continue; | |
| } | |
| if (entry.type !== 'message' || entry.message?.role !== 'assistant') continue; | |
| const content = entry.message.content; | |
| const parts = []; | |
| if (typeof content === 'string') { | |
| parts.push(content); | |
| } else if (Array.isArray(content)) { | |
| for (const block of content) { | |
| if (block?.type === 'text' && typeof block.text === 'string') { | |
| parts.push(block.text); | |
| } | |
| } | |
| } | |
| const text = parts.join('\n\n').trim(); | |
| if (text) lastText = text; | |
| } | |
| if (!lastText) return '_No assistant output found._'; | |
| const maxLength = 55000; | |
| if (lastText.length <= maxLength) return lastText; | |
| return `${lastText.slice(0, maxLength)}\n\n_[truncated]_`; | |
| } | |
| const gistUrl = process.env.GIST_URL; | |
| const gistId = process.env.GIST_ID; | |
| const shareUrl = process.env.SHARE_URL; | |
| const lastAgentMessage = extractLastAgentMessage(process.env.SESSION_JSONL); | |
| const body = [ | |
| 'Pi issue analysis finished.', | |
| '', | |
| `Share URL: ${shareUrl}`, | |
| `Gist: ${gistUrl}`, | |
| '', | |
| 'Continue locally from a checkout with:', | |
| '', | |
| '```sh', | |
| `pi "/ir ${gistId}"`, | |
| '```', | |
| '', | |
| '<details>', | |
| '<summary>Agent analysis summary</summary>', | |
| '', | |
| lastAgentMessage, | |
| '', | |
| '</details>', | |
| ].join('\n'); | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| body, | |
| }); | |
| - name: Remove trigger label | |
| if: always() | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| name: 'pi-analyze', | |
| }); | |
| } catch (error) { | |
| if (error.status !== 404) throw error; | |
| } |