Skip to content

Commit a20b030

Browse files
committed
fix: harden diagnostics for large configs
1 parent 21e64ba commit a20b030

6 files changed

Lines changed: 170 additions & 59 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
All notable user-facing changes are recorded here. This project follows semantic versioning while it is in the `0.x` development series.
44

5+
## [0.1.38] - 2026-07-18
6+
7+
### Fixed
8+
9+
- `supertask doctor` now reads large resolved OpenCode configurations through a private temporary file, avoiding truncated JSON when OpenCode exits before a captured stdout pipe is fully drained.
10+
- Successful runs no longer label truncated or unstructured JSONL fragments as failure reasons in task details and execution logs.
11+
12+
[0.1.38]: https://github.com/vbgate/opencode-supertask/compare/v0.1.37...v0.1.38
13+
514
## [0.1.37] - 2026-07-18
615

716
### Fixed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opencode-supertask",
3-
"version": "0.1.37",
3+
"version": "0.1.38",
44
"description": "AI Agent 任务调度系统 — OpenCode 插件 + CLI + Gateway 常驻进程",
55
"type": "module",
66
"main": "dist/plugin/supertask.js",

src/daemon/update.ts

Lines changed: 83 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
import { spawnSync } from 'child_process';
2-
import { existsSync, readFileSync, readdirSync, realpathSync } from 'fs';
3-
import { homedir } from 'os';
2+
import {
3+
chmodSync,
4+
closeSync,
5+
existsSync,
6+
mkdtempSync,
7+
openSync,
8+
readFileSync,
9+
readdirSync,
10+
realpathSync,
11+
rmSync,
12+
} from 'fs';
13+
import { homedir, tmpdir } from 'os';
414
import { dirname, join, resolve } from 'path';
515
import { compareSemanticVersions, isSemanticVersion } from '@core/semver';
616

@@ -325,11 +335,6 @@ export function resolveConfiguredPluginSpec(value: unknown): ConfiguredPluginSpe
325335
}
326336

327337
export function getOpenCodePluginDiagnostic(): OpenCodePluginDiagnostic {
328-
const result = spawnSync(opencodeBin(), ['debug', 'config', '--pure'], {
329-
encoding: 'utf8',
330-
env: process.env,
331-
timeout: 30_000,
332-
});
333338
const failed = (message: string): OpenCodePluginDiagnostic => ({
334339
ok: false,
335340
spec: '',
@@ -339,53 +344,81 @@ export function getOpenCodePluginDiagnostic(): OpenCodePluginDiagnostic {
339344
packageDir: null,
340345
error: message,
341346
});
342-
if (result.error) {
343-
return failed(`[supertask] 无法读取 OpenCode 最终配置: ${result.error.message}`);
344-
}
345-
if (result.status !== 0) {
346-
const detail = `${result.stderr ?? ''}`.trim();
347-
return failed(`[supertask] 无法读取 OpenCode 最终配置: ${detail || `退出码 ${result.status}`}`);
348-
}
347+
let outputDirectory: string | null = null;
348+
let outputFd: number | null = null;
349349

350-
let config: unknown;
351350
try {
352-
config = JSON.parse(`${result.stdout ?? ''}`);
353-
} catch {
354-
return failed('[supertask] OpenCode 最终配置不是有效 JSON');
355-
}
351+
outputDirectory = mkdtempSync(join(tmpdir(), 'opencode-supertask-config-'));
352+
chmodSync(outputDirectory, 0o700);
353+
const outputPath = join(outputDirectory, 'resolved-config.json');
354+
outputFd = openSync(outputPath, 'w', 0o600);
355+
const result = spawnSync(opencodeBin(), ['debug', 'config', '--pure'], {
356+
encoding: 'utf8',
357+
env: process.env,
358+
timeout: 30_000,
359+
stdio: ['ignore', outputFd, 'pipe'],
360+
});
361+
closeSync(outputFd);
362+
outputFd = null;
363+
if (result.error) {
364+
return failed(`[supertask] 无法读取 OpenCode 最终配置: ${result.error.message}`);
365+
}
366+
if (result.status !== 0) {
367+
const detail = `${result.stderr ?? ''}`.trim();
368+
return failed(`[supertask] 无法读取 OpenCode 最终配置: ${detail || `退出码 ${result.status}`}`);
369+
}
356370

357-
let configured: ConfiguredPluginSpec;
358-
try {
359-
configured = resolveConfiguredPluginSpec(config);
360-
} catch (error) {
361-
return failed(error instanceof Error ? error.message : String(error));
362-
}
363-
if (!configured.exact || configured.version === null) {
364-
return {
365-
ok: false,
366-
...configured,
367-
cachedVersion: null,
368-
packageDir: null,
369-
error: `[supertask] OpenCode 插件必须固定精确版本,不能使用 ${configured.spec}`,
370-
};
371-
}
372-
try {
373-
const installed = resolveInstalledPluginVersion(configured.version);
374-
return {
375-
ok: true,
376-
...configured,
377-
cachedVersion: installed.version,
378-
packageDir: installed.packageDir,
379-
error: null,
380-
};
371+
let config: unknown;
372+
try {
373+
config = JSON.parse(readFileSync(outputPath, 'utf8'));
374+
} catch {
375+
return failed('[supertask] OpenCode 最终配置不是有效 JSON');
376+
}
377+
378+
let configured: ConfiguredPluginSpec;
379+
try {
380+
configured = resolveConfiguredPluginSpec(config);
381+
} catch (error) {
382+
return failed(error instanceof Error ? error.message : String(error));
383+
}
384+
if (!configured.exact || configured.version === null) {
385+
return {
386+
ok: false,
387+
...configured,
388+
cachedVersion: null,
389+
packageDir: null,
390+
error: `[supertask] OpenCode 插件必须固定精确版本,不能使用 ${configured.spec}`,
391+
};
392+
}
393+
try {
394+
const installed = resolveInstalledPluginVersion(configured.version);
395+
return {
396+
ok: true,
397+
...configured,
398+
cachedVersion: installed.version,
399+
packageDir: installed.packageDir,
400+
error: null,
401+
};
402+
} catch (error) {
403+
return {
404+
ok: false,
405+
...configured,
406+
cachedVersion: null,
407+
packageDir: null,
408+
error: error instanceof Error ? error.message : String(error),
409+
};
410+
}
381411
} catch (error) {
382-
return {
383-
ok: false,
384-
...configured,
385-
cachedVersion: null,
386-
packageDir: null,
387-
error: error instanceof Error ? error.message : String(error),
388-
};
412+
return failed(`[supertask] 无法读取 OpenCode 最终配置: ${error instanceof Error ? error.message : String(error)}`);
413+
} finally {
414+
if (outputFd !== null) {
415+
try {
416+
closeSync(outputFd);
417+
} catch {
418+
// The descriptor may already be closed after a spawn failure.
419+
}
420+
}
421+
if (outputDirectory !== null) rmSync(outputDirectory, { recursive: true, force: true });
389422
}
390423
}
391424

src/web/index.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ function shellQuote(value: string): string {
394394
return `'${value.replace(/'/g, `'"'"'`)}'`;
395395
}
396396

397-
export function presentRunLog(log: string): RunLogPresentation {
397+
export function presentRunLog(log: string, includeErrors = true): RunLogPresentation {
398398
let command: RunCommandPresentation | null = null;
399399
const textParts: string[] = [];
400400
const errors: string[] = [];
@@ -407,7 +407,7 @@ export function presentRunLog(log: string): RunLogPresentation {
407407
try {
408408
parsed = recordValue(JSON.parse(trimmed) as unknown);
409409
} catch {
410-
errors.push(line);
410+
if (includeErrors) errors.push(line);
411411
continue;
412412
}
413413
if (!parsed) continue;
@@ -443,7 +443,7 @@ export function presentRunLog(log: string): RunLogPresentation {
443443
const error = typeof parsed.error === 'string'
444444
? parsed.error
445445
: typeof part?.error === 'string' ? part.error : null;
446-
if (error) errors.push(error);
446+
if (error && includeErrors) errors.push(error);
447447
}
448448

449449
return {
@@ -454,8 +454,8 @@ export function presentRunLog(log: string): RunLogPresentation {
454454
};
455455
}
456456

457-
function renderRunLog(runId: number, taskName: string, log: string, locale: Locale): string {
458-
const presentation = presentRunLog(log);
457+
function renderRunLog(runId: number, taskName: string, log: string, locale: Locale, includeErrors: boolean): string {
458+
const presentation = presentRunLog(log, includeErrors);
459459
const command = presentation.command
460460
? `<div class="run-command"><div class="log-section-head"><strong>${t(locale, 'logs.command')}</strong><button type="button" class="btn" onclick="copyRunCommand(${runId})">${icon('copy')}${t(locale, 'action.copyCommand')}</button></div><div class="command-cwd">${esc(presentation.command.cwd)}</div><pre id="command-${runId}">${esc(presentation.command.command)}</pre></div>`
461461
: '';
@@ -870,7 +870,7 @@ app.get('/runs', async (c) => {
870870
const rows = runs.map((run) => {
871871
const status = safeStatus(run.status);
872872
const resumable = isValidSessionId(run.sessionId);
873-
const log = run.log ? renderRunLog(run.id, run.taskName, run.log, locale) : '';
873+
const log = run.log ? renderRunLog(run.id, run.taskName, run.log, locale, run.status !== 'done') : '';
874874
return `<tr class="run-summary-row">
875875
<td class="faint" data-label="${t(locale, 'table.run')}">#${run.id}</td>
876876
<td data-primary data-label="${t(locale, 'table.task')}"><div class="task-name">${esc(run.taskName)} <span class="faint">#${run.taskId}</span></div>${run.model ? `<div style="margin-top:4px"><span class="tag">${esc(run.model)}</span></div>` : ''}</td>
@@ -1012,7 +1012,7 @@ app.get('/api/tasks/:id', async (c) => {
10121012
const runs = await TaskRunService.listByTaskId(id);
10131013
return c.json({
10141014
...task,
1015-
_resultPresentation: task.resultLog ? presentRunLog(task.resultLog) : null,
1015+
_resultPresentation: task.resultLog ? presentRunLog(task.resultLog, task.status !== 'done') : null,
10161016
_runs: runs,
10171017
});
10181018
});

tests/dashboard-security.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,24 @@ describe('Dashboard 安全边界', () => {
480480
expect(presented.errors).toEqual(['opencode 退出码 1']);
481481
});
482482

483+
test('成功执行的截断 JSONL 片段不显示为失败原因', () => {
484+
const command = JSON.stringify({
485+
type: 'supertask_command',
486+
executable: 'opencode',
487+
cwd: '/tmp/project',
488+
args: ['run', '--agent', 'build', '--format', 'json', 'test'],
489+
});
490+
const output = JSON.stringify({
491+
type: 'text',
492+
part: { type: 'text', text: '任务完成' },
493+
});
494+
495+
const presented = presentRunLog(`${command}\n截断的 JSON 字符串尾部"}}\n${output}`, false);
496+
497+
expect(presented.text).toBe('任务完成');
498+
expect(presented.errors).toEqual([]);
499+
});
500+
483501
test('异常 Session ID 不生成终端命令', async () => {
484502
const task = await TaskService.add({ name: '异常会话', agent: 'a', prompt: 'p' });
485503
const run = await TaskRunService.create({ taskId: task.id, status: 'running' });

tests/update.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import { afterEach, describe, expect, test } from 'bun:test';
2-
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'fs';
2+
import {
3+
chmodSync,
4+
existsSync,
5+
mkdirSync,
6+
mkdtempSync,
7+
readFileSync,
8+
readdirSync,
9+
realpathSync,
10+
rmSync,
11+
writeFileSync,
12+
} from 'fs';
313
import { tmpdir } from 'os';
414
import { join } from 'path';
515
import {
@@ -20,6 +30,7 @@ const originalEnv = {
2030
npmBin: process.env.SUPERTASK_NPM_BIN,
2131
cache: process.env.SUPERTASK_OPENCODE_CACHE_DIR,
2232
packageDir: process.env.SUPERTASK_PLUGIN_PACKAGE_DIR,
33+
tmpDir: process.env.TMPDIR,
2334
};
2435

2536
function restoreEnv(name: string, value: string | undefined): void {
@@ -35,6 +46,7 @@ afterEach(() => {
3546
restoreEnv('SUPERTASK_NPM_BIN', originalEnv.npmBin);
3647
restoreEnv('SUPERTASK_OPENCODE_CACHE_DIR', originalEnv.cache);
3748
restoreEnv('SUPERTASK_PLUGIN_PACKAGE_DIR', originalEnv.packageDir);
49+
restoreEnv('TMPDIR', originalEnv.tmpDir);
3850
});
3951

4052
function writePlugin(packageDir: string, version: string): void {
@@ -86,6 +98,45 @@ process.exit(1);
8698
});
8799
});
88100

101+
test('通过私有临时文件读取超过管道缓冲区的最终配置并在完成后清理', () => {
102+
const dir = mkdtempSync(join(tmpdir(), 'supertask-doctor-large-config-'));
103+
dirs.push(dir);
104+
const packageDir = join(dir, 'package');
105+
const temporaryRoot = join(dir, 'tmp');
106+
const fakeOpencode = join(dir, 'opencode');
107+
const stdoutStatFile = join(dir, 'stdout-stat.json');
108+
writePlugin(packageDir, '0.1.31');
109+
mkdirSync(temporaryRoot);
110+
writeFileSync(fakeOpencode, `#!/usr/bin/env bun
111+
import { fstatSync, writeFileSync } from 'fs';
112+
const config = JSON.stringify({
113+
plugin: ['opencode-supertask@0.1.31'],
114+
agent: { large: { prompt: 'x'.repeat(128 * 1024) } },
115+
});
116+
const stdout = fstatSync(1);
117+
writeFileSync(${JSON.stringify(stdoutStatFile)}, JSON.stringify({
118+
isFile: stdout.isFile(),
119+
mode: stdout.mode & 0o777,
120+
}));
121+
process.stdout.write(stdout.isFile() ? config : config.slice(0, 64 * 1024));
122+
`);
123+
chmodSync(fakeOpencode, 0o755);
124+
process.env.SUPERTASK_OPENCODE_BIN = fakeOpencode;
125+
process.env.SUPERTASK_PLUGIN_PACKAGE_DIR = packageDir;
126+
process.env.TMPDIR = temporaryRoot;
127+
128+
expect(getOpenCodePluginDiagnostic()).toMatchObject({
129+
ok: true,
130+
spec: 'opencode-supertask@0.1.31',
131+
version: '0.1.31',
132+
});
133+
expect(JSON.parse(readFileSync(stdoutStatFile, 'utf8'))).toEqual({
134+
isFile: true,
135+
mode: 0o600,
136+
});
137+
expect(readdirSync(temporaryRoot)).toEqual([]);
138+
});
139+
89140
test('诊断拒绝 latest,即使旧缓存目录仍可读取', () => {
90141
const dir = mkdtempSync(join(tmpdir(), 'supertask-doctor-latest-'));
91142
dirs.push(dir);

0 commit comments

Comments
 (0)