Skip to content

Commit 9d34c5d

Browse files
detainclaude
andcommitted
fix(github-code-review): resume opencode session on leaked tool-call instead of restarting
The MiniMax/SGLang tool-parse leak happens at the END of a run — after the review is done — on a bookkeeping tool call (e.g. todowrite). Restarting from scratch threw the finished review away and, since the agent already edited files on disk, the fresh pass could no longer re-find the now-fixed bug (dropping it from the report). On a detected leak the worker now RESUMES the same opencode session (`opencode run -s <id>` / `-c`) with a nudge to call no more tools and just emit the final report, salvaging the work and avoiding a re-trigger. - extractOpencodeSessionId(): scrape the session id from the --format json stream - getResumeNudge(): "no tools; output only the final report in the required format; report already-applied fixes as FIXED" (OPENCODE_RESUME_NUDGE override) - buildOpencodeResumeCommand()/selectOpencodeAttemptCommand(): -s <id> else -c; attempt 1 = full prompt, later attempts = resume - runOpencodeAnalysis(): leak -> resume; empty resume -> one fresh full-prompt fallback (bounded by OPENCODE_MAX_ATTEMPTS, no infinite loop); temp files cleaned in finally. Parsing/posting/baselines unchanged. - New OpencodeResumeTest (11 tests, network-free). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 04ba536 commit 9d34c5d

2 files changed

Lines changed: 315 additions & 9 deletions

File tree

scripts/github-code-review.php

Lines changed: 153 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2620,12 +2620,110 @@ function prepareOpencodeCommand(string $jobId, ?string $promptText = null): arra
26202620
return ['cmd' => $cmd, 'promptFile' => $promptFile];
26212621
}
26222622

2623+
/**
2624+
* Scrape the opencode session id out of raw `--format json` NDJSON so a session
2625+
* that leaked a tool call can be RESUMED (opencode run -s <id>) instead of
2626+
* restarted from scratch. Pure and network-free.
2627+
*
2628+
* Prefers an explicit JSON key ("sessionID" / "session_id" / "sessionId");
2629+
* otherwise falls back to a bare `ses_...` token. Returns '' when nothing is
2630+
* found (the caller then resumes with -c, i.e. "continue the last session").
2631+
*/
2632+
function extractOpencodeSessionId(string $rawOutput): string
2633+
{
2634+
if ($rawOutput === '') {
2635+
return '';
2636+
}
2637+
if (preg_match('/"session(?:ID|_id|Id)"\s*:\s*"([^"]+)"/', $rawOutput, $m)) {
2638+
return $m[1];
2639+
}
2640+
if (preg_match('/\bses_[A-Za-z0-9]{6,}\b/', $rawOutput, $m)) {
2641+
return $m[0];
2642+
}
2643+
return '';
2644+
}
2645+
2646+
/**
2647+
* The continuation instruction sent when RESUMING a leaked session. The review
2648+
* work (and any in-place fixes) is already done on disk, so this asks the model
2649+
* to emit ONLY the final report and to call no further tools — which both
2650+
* salvages the completed review and avoids re-triggering the same tool-call
2651+
* leak. Overridable via OPENCODE_RESUME_NUDGE.
2652+
*/
2653+
function getResumeNudge(): string
2654+
{
2655+
$override = getenv('OPENCODE_RESUME_NUDGE');
2656+
if (is_string($override) && trim($override) !== '') {
2657+
return $override;
2658+
}
2659+
return <<<'NUDGE'
2660+
Your previous turn ended with leaked/unparsed tool-call markup (for example a
2661+
todowrite or bash call emitted as plain text) INSTEAD of finishing the review.
2662+
The review work and any code fixes are already complete on disk.
2663+
2664+
Do NOT call any tools now — no todowrite, no bash, no read, no edit, nothing.
2665+
Output ONLY the final code-review report immediately, in the EXACT required
2666+
format:
2667+
2668+
#### <emoji> <title> — <path>:<line>
2669+
<a single FIXED or FLAGGED paragraph describing the issue>
2670+
2671+
Repeat that block for every issue. Any fix you already applied on disk MUST be
2672+
reported as FIXED. End with exactly one line:
2673+
2674+
Fixed N of M issues.
2675+
NUDGE;
2676+
}
2677+
2678+
/**
2679+
* Build the opencode command that RESUMES an existing review session to finish
2680+
* the report after a tool-call leak, plus the nudge file it reads.
2681+
*
2682+
* -s <id> continue a SPECIFIC session (used when a session id was scraped)
2683+
* -c continue the LAST session (fallback when no id was found)
2684+
*
2685+
* The nudge is passed via `$(cat FILE)` for the same shell-quoting safety as
2686+
* prepareOpencodeCommand, and `--format json` is kept so the subagent-reporter
2687+
* plugin and the NDJSON parsers keep working on the resumed turn.
2688+
*
2689+
* @return array{cmd: string, promptFile: string}
2690+
*/
2691+
function buildOpencodeResumeCommand(string $jobId, string $sessionId): array
2692+
{
2693+
$promptFile = '/tmp/opencode-resume-' . $jobId . '.txt';
2694+
file_put_contents($promptFile, getResumeNudge());
2695+
$resume = $sessionId !== '' ? '-s ' . escapeshellarg($sessionId) : '-c';
2696+
$cmd = sprintf('opencode run %s "$(cat %s)" --format json', $resume, escapeshellarg($promptFile));
2697+
return ['cmd' => $cmd, 'promptFile' => $promptFile];
2698+
}
2699+
2700+
/**
2701+
* Pick the opencode command for a given retry attempt.
2702+
*
2703+
* Attempt 1 always runs the full review prompt. Later attempts RESUME the prior
2704+
* session with the short finish-the-report nudge — UNLESS a previous resume
2705+
* produced no output ($forceFresh), in which case we fall back to one fresh
2706+
* full-prompt run so an unresumable session can't spin in an empty loop.
2707+
*
2708+
* Pure aside from writing the prompt/nudge temp file (like prepareOpencodeCommand),
2709+
* so tests can assert the per-attempt selection without invoking opencode.
2710+
*
2711+
* @return array{cmd: string, promptFile: string, mode: string}
2712+
*/
2713+
function selectOpencodeAttemptCommand(string $jobId, int $attempt, string $sessionId, bool $forceFresh = false, ?string $promptText = null): array
2714+
{
2715+
if ($attempt <= 1 || $forceFresh) {
2716+
$prep = prepareOpencodeCommand($jobId, $promptText);
2717+
return ['cmd' => $prep['cmd'], 'promptFile' => $prep['promptFile'], 'mode' => 'fresh'];
2718+
}
2719+
$resume = buildOpencodeResumeCommand($jobId, $sessionId);
2720+
return ['cmd' => $resume['cmd'], 'promptFile' => $resume['promptFile'], 'mode' => 'resume'];
2721+
}
2722+
26232723
function runOpencodeAnalysis(string $dir, string $jobId, string $cmdTemplate, bool $showAgent = false, int $timeout = 1800, ?string $promptText = null): string
26242724
{
26252725
global $running, $verbose, $logFile;
26262726

2627-
['cmd' => $opencodeCmd, 'promptFile' => $promptFile] = prepareOpencodeCommand($jobId, $promptText);
2628-
26292727
// Log what changed — kept OUT of the analyzed command output so diff
26302728
// content can never be mistaken for review findings by the parsers
26312729
if ($verbose >= 2) {
@@ -2641,16 +2739,30 @@ function runOpencodeAnalysis(string $dir, string $jobId, string $cmdTemplate, bo
26412739

26422740
// The model (MiniMax-M2 via SGLang) occasionally emits its tool-call markup
26432741
// as plain text and then ends the turn mid-review — a server-side tool-call
2644-
// parse leak. When we detect that, re-run the review (the PR baseline is the
2645-
// committed snapshot, so `git diff _base HEAD` is stable across attempts and
2646-
// in-place fixes converge idempotently). Tunable via OPENCODE_MAX_ATTEMPTS
2647-
// (default 3, i.e. 2 retries).
2742+
// parse leak. In practice this happens AT THE END, after the real review is
2743+
// done, on a bookkeeping call (e.g. todowrite). Restarting from scratch
2744+
// would throw the finished review away AND, because the agent already edited
2745+
// files on disk, the fresh pass would no longer re-find the now-fixed bug.
2746+
// So on a detected leak we RESUME the same opencode session (opencode run -s
2747+
// <id> / -c) with a short nudge to just finish the report and call no more
2748+
// tools — salvaging the work and avoiding a re-trigger. Tunable via
2749+
// OPENCODE_MAX_ATTEMPTS (default 3, i.e. 2 retries).
26482750
$maxAttempts = max(1, (int)(getenv('OPENCODE_MAX_ATTEMPTS') ?: 3));
26492751
$result = '';
2752+
$sessionId = '';
2753+
$forceFresh = false; // set when a resume produced nothing → fall back to a fresh run
2754+
$tempFiles = []; // every prompt/nudge temp file we wrote, unlinked in finally
26502755

26512756
try {
26522757
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
2653-
$suffix = ($stream ? ' [streaming]' : '') . ($attempt > 1 ? " (attempt {$attempt}/{$maxAttempts})" : '');
2758+
$selection = selectOpencodeAttemptCommand($jobId, $attempt, $sessionId, $forceFresh, $promptText);
2759+
$opencodeCmd = $selection['cmd'];
2760+
$mode = $selection['mode'];
2761+
$tempFiles[$selection['promptFile']] = true;
2762+
$forceFresh = false; // consumed for this attempt
2763+
2764+
$modeSuffix = ($attempt > 1 && $mode === 'resume') ? ' [resume]' : '';
2765+
$suffix = ($stream ? ' [streaming]' : '') . ($attempt > 1 ? " (attempt {$attempt}/{$maxAttempts}{$modeSuffix})" : '');
26542766
verbose_log("runOpencodeAnalysis: executing analysis in {$dir}{$suffix}", 3);
26552767

26562768
$run = runStreamedCommand($opencodeCmd, $dir, $timeout, $stream);
@@ -2662,16 +2774,46 @@ function runOpencodeAnalysis(string $dir, string $jobId, string $cmdTemplate, bo
26622774
if ($run['stderr'] !== '') {
26632775
verbose_raw("opencode_stderr", $run['stderr']);
26642776
}
2777+
2778+
// A resume that produced nothing means the session couldn't be
2779+
// continued (not found / resume failed). Rather than overwrite the
2780+
// salvageable partial with '' or loop resuming an unresumable
2781+
// session forever, fall back to ONE fresh full-prompt run.
2782+
if ($mode === 'resume' && trim($run['output']) === '') {
2783+
if ($attempt < $maxAttempts) {
2784+
verbose_log("runOpencodeAnalysis: resume produced no output (session not found?); falling back to a fresh full-prompt run (" . ($attempt + 1) . "/{$maxAttempts})", 1);
2785+
$forceFresh = true;
2786+
usleep(500000); // brief backoff before retry
2787+
if (function_exists('pcntl_signal_dispatch')) {
2788+
pcntl_signal_dispatch();
2789+
}
2790+
// @phpstan-ignore-next-line — $running is mutated by the async signal handler
2791+
if (!$running) {
2792+
return '';
2793+
}
2794+
continue;
2795+
}
2796+
verbose_log("runOpencodeAnalysis: resume produced no output on the final attempt; using best-effort partial", 1);
2797+
break;
2798+
}
2799+
26652800
$result = $run['output'];
26662801

2802+
// Capture the session id from the first attempt that exposes one so
2803+
// a later leak resumes the SAME session (falls back to -c otherwise).
2804+
if ($sessionId === '') {
2805+
$sessionId = extractOpencodeSessionId($result);
2806+
}
2807+
26672808
if ($run['timedOut']) {
26682809
// A timeout is not a parse leak — keep whatever partial we have
26692810
verbose_log("runOpencodeAnalysis: analysis timed out after {$timeout}s, using partial output", 1);
26702811
break;
26712812
}
26722813

26732814
if (looksLikeLeakedToolCall($result) && $attempt < $maxAttempts) {
2674-
verbose_log("runOpencodeAnalysis: output ended on leaked tool-call markup (model/SGLang tool-parse leak); retrying fresh (" . ($attempt + 1) . "/{$maxAttempts})", 1);
2815+
$target = $sessionId !== '' ? "session {$sessionId}" : 'the last session (-c)';
2816+
verbose_log("runOpencodeAnalysis: output ended on leaked tool-call markup (model/SGLang tool-parse leak); resuming {$target} to finish the report (" . ($attempt + 1) . "/{$maxAttempts})", 1);
26752817
usleep(500000); // brief backoff before retry
26762818
if (function_exists('pcntl_signal_dispatch')) {
26772819
pcntl_signal_dispatch();
@@ -2689,7 +2831,9 @@ function runOpencodeAnalysis(string $dir, string $jobId, string $cmdTemplate, bo
26892831
break;
26902832
}
26912833
} finally {
2692-
@unlink($promptFile);
2834+
foreach (array_keys($tempFiles) as $f) {
2835+
@unlink($f);
2836+
}
26932837
}
26942838

26952839
verbose_log("runOpencodeAnalysis: completed, output length=" . strlen($result), 3);
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Webhooks\Tests\Unit\CodeReviewWorker;
5+
6+
use PHPUnit\Framework\TestCase;
7+
8+
/**
9+
* Tests for the leaked-tool-call RESUME path in scripts/github-code-review.php:
10+
* extractOpencodeSessionId(), getResumeNudge(), buildOpencodeResumeCommand(),
11+
* and the per-attempt command selection in selectOpencodeAttemptCommand().
12+
*
13+
* All network-free — no opencode process is ever invoked; only the pure command
14+
* builders and scrapers are exercised.
15+
*/
16+
class OpencodeResumeTest extends TestCase
17+
{
18+
/** @var string[] */
19+
private array $tmpFiles = [];
20+
21+
protected function setUp(): void
22+
{
23+
require_once __DIR__ . '/../../../../scripts/github-code-review.php';
24+
$GLOBALS['verbose'] = 0;
25+
$GLOBALS['logFile'] = null;
26+
}
27+
28+
protected function tearDown(): void
29+
{
30+
foreach ($this->tmpFiles as $f) {
31+
if (is_file($f)) {
32+
@unlink($f);
33+
}
34+
}
35+
putenv('OPENCODE_RESUME_NUDGE');
36+
}
37+
38+
// ==========================================
39+
// extractOpencodeSessionId
40+
// ==========================================
41+
42+
public function testExtractsSessionIdFromNdjsonJsonKey(): void
43+
{
44+
$ndjson = implode("\n", [
45+
'{"type":"start","sessionID":"ses_abc123DEF","model":"minimax-m2"}',
46+
'{"type":"text","part":{"text":"reviewing..."}}',
47+
]);
48+
$this->assertSame('ses_abc123DEF', extractOpencodeSessionId($ndjson));
49+
}
50+
51+
public function testExtractsSessionIdFromSnakeCaseKey(): void
52+
{
53+
$this->assertSame('ses_snake99', extractOpencodeSessionId('{"session_id":"ses_snake99"}'));
54+
}
55+
56+
public function testFallsBackToBareSessionToken(): void
57+
{
58+
// No JSON key, but a bare ses_ token appears somewhere in the stream
59+
$this->assertSame('ses_bareToken12', extractOpencodeSessionId("session started: ses_bareToken12 ok\n"));
60+
}
61+
62+
public function testReturnsEmptyWhenNoSessionIdPresent(): void
63+
{
64+
$this->assertSame('', extractOpencodeSessionId('{"type":"text","part":{"text":"no id here"}}'));
65+
$this->assertSame('', extractOpencodeSessionId(''));
66+
}
67+
68+
// ==========================================
69+
// getResumeNudge
70+
// ==========================================
71+
72+
public function testResumeNudgeMentionsNoToolsAndRequiredFormat(): void
73+
{
74+
$nudge = getResumeNudge();
75+
$this->assertNotSame('', trim($nudge));
76+
// Instructs the model not to call any more tools
77+
$this->assertMatchesRegularExpression('/do not call any tools/i', $nudge);
78+
$this->assertStringContainsString('todowrite', $nudge);
79+
// Names the exact required report format + the trailing tally line
80+
$this->assertStringContainsString('#### <emoji> <title>', $nudge);
81+
$this->assertStringContainsString('Fixed N of M issues.', $nudge);
82+
}
83+
84+
public function testResumeNudgeHonorsEnvOverride(): void
85+
{
86+
putenv('OPENCODE_RESUME_NUDGE=just finish the report please');
87+
$this->assertSame('just finish the report please', getResumeNudge());
88+
}
89+
90+
// ==========================================
91+
// buildOpencodeResumeCommand
92+
// ==========================================
93+
94+
public function testResumeCommandUsesSpecificSessionWhenIdKnown(): void
95+
{
96+
$jobId = 'unit-resume-' . getmypid();
97+
$res = buildOpencodeResumeCommand($jobId, 'ses_xyz789');
98+
$this->tmpFiles[] = $res['promptFile'];
99+
100+
$this->assertSame("/tmp/opencode-resume-{$jobId}.txt", $res['promptFile']);
101+
$this->assertFileExists($res['promptFile']);
102+
// -s <escaped id>, cats the nudge file, keeps --format json
103+
$this->assertStringContainsString('opencode run -s ' . escapeshellarg('ses_xyz789'), $res['cmd']);
104+
$this->assertStringContainsString('"$(cat ' . escapeshellarg($res['promptFile']) . ')"', $res['cmd']);
105+
$this->assertStringContainsString('--format json', $res['cmd']);
106+
// The nudge file holds exactly the resume nudge
107+
$this->assertSame(getResumeNudge(), file_get_contents($res['promptFile']));
108+
}
109+
110+
public function testResumeCommandUsesContinueFlagWhenNoId(): void
111+
{
112+
$jobId = 'unit-resume-c-' . getmypid();
113+
$res = buildOpencodeResumeCommand($jobId, '');
114+
$this->tmpFiles[] = $res['promptFile'];
115+
116+
$this->assertStringContainsString('opencode run -c "$(cat ', $res['cmd']);
117+
$this->assertStringNotContainsString('-s ', $res['cmd']);
118+
$this->assertStringContainsString('--format json', $res['cmd']);
119+
}
120+
121+
// ==========================================
122+
// selectOpencodeAttemptCommand — per-attempt routing
123+
// ==========================================
124+
125+
public function testAttemptOneUsesFullPromptCommand(): void
126+
{
127+
$jobId = 'unit-sel1-' . getmypid();
128+
$sel = selectOpencodeAttemptCommand($jobId, 1, '');
129+
$this->tmpFiles[] = $sel['promptFile'];
130+
131+
$this->assertSame('fresh', $sel['mode']);
132+
$this->assertSame("/tmp/opencode-prompt-{$jobId}.txt", $sel['promptFile']);
133+
$this->assertStringContainsString('opencode run "$(cat ', $sel['cmd']);
134+
$this->assertStringNotContainsString('-s ', $sel['cmd']);
135+
$this->assertStringNotContainsString('opencode run -c', $sel['cmd']);
136+
}
137+
138+
public function testLaterAttemptUsesResumeCommand(): void
139+
{
140+
$jobId = 'unit-sel2-' . getmypid();
141+
$sel = selectOpencodeAttemptCommand($jobId, 2, 'ses_keep42');
142+
$this->tmpFiles[] = $sel['promptFile'];
143+
144+
$this->assertSame('resume', $sel['mode']);
145+
$this->assertSame("/tmp/opencode-resume-{$jobId}.txt", $sel['promptFile']);
146+
$this->assertStringContainsString('opencode run -s ' . escapeshellarg('ses_keep42'), $sel['cmd']);
147+
$this->assertStringContainsString('--format json', $sel['cmd']);
148+
}
149+
150+
public function testForceFreshOverridesResumeOnLaterAttempt(): void
151+
{
152+
// When a prior resume returned nothing, the loop forces a fresh run even
153+
// on attempt > 1 to avoid an empty-resume loop.
154+
$jobId = 'unit-sel3-' . getmypid();
155+
$sel = selectOpencodeAttemptCommand($jobId, 3, 'ses_ignored', true);
156+
$this->tmpFiles[] = $sel['promptFile'];
157+
158+
$this->assertSame('fresh', $sel['mode']);
159+
$this->assertStringContainsString('opencode run "$(cat ', $sel['cmd']);
160+
$this->assertStringNotContainsString('-s ', $sel['cmd']);
161+
}
162+
}

0 commit comments

Comments
 (0)