Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/docs-sync/edit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,11 @@ Batch specifics for this run: the PRs to handle are in the attached ${batchFile}
break
}

// Headless `kilo run` auto-rejects every permission ask; the runner has no
// user config granting bash, so without --auto the agent cannot run ordinary
// shell commands against the repository.
const result = runKilo({
args: ["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile],
args: ["run", "--auto", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile],
timeoutMs: Math.min(BATCH_TIMEOUT_MS, left),
streamStdout: true,
label: `edit batch ${index} attempt ${attempt}`,
Expand Down
22 changes: 21 additions & 1 deletion .github/docs-sync/lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -174,13 +174,20 @@ function tailText(text, { lines = STDERR_TAIL_LINES, chars = STDERR_TAIL_CHARS }
return lastLines.length > chars ? lastLines.slice(-chars) : lastLines
}

/** Max bytes of child stderr persisted to docs-sync-out/ (full buffer, not the console tail). */
const STDERR_LOG_MAX_CHARS = 8 * 1024 * 1024

/**
* Run `kilo` via spawnSync so stderr is always recoverable — including when
* the child exits 0 after writing a diagnostic (execFileSync cannot return
* piped stderr on exit 0; that path lost every diagnostic on run 30122603016).
*
* streamStdout:true → inherit fd 1 (edit live log); false → capture stdout
* (triage parses it). stderr is always buffered.
*
* Always writes the full captured stderr to
* docs-sync-out/kilo-stderr-<sanitized-label>.log (unconditional — success and
* failure). The console return value still uses the short tailText.
*/
export function runKilo({ args, timeoutMs, streamStdout = false, label = "kilo" }) {
const result = spawnSync("kilo", args, {
Expand All @@ -193,13 +200,26 @@ export function runKilo({ args, timeoutMs, streamStdout = false, label = "kilo"
const timedOut = Boolean(result.error && result.error.code === "ETIMEDOUT")
const exitCode =
typeof result.status === "number" ? result.status : timedOut ? null : result.status === null ? null : result.status
const stderrTail = tailText(result.stderr)
const stderrRaw = String(result.stderr ?? "")
const stderrTail = tailText(stderrRaw)
const stdout = streamStdout ? "" : String(result.stdout ?? "")
// ok is "process finished without OS-level failure". Callers still treat a
// missing summary / unparseable output as failure even when ok is true —
// exit 0 is not success for the docs-sync bot.
const ok = !result.error && result.status === 0

// Persist full stderr on every call (not gated on ok/exitCode/summary). Cap is
// generous (megabytes) so long batch dumps keep auto-rejecting lines; console
// still uses the short tail above.
try {
fs.mkdirSync("docs-sync-out", { recursive: true })
const safe = label.replace(/[^A-Za-z0-9._-]/g, "-")
const body = stderrRaw.length > STDERR_LOG_MAX_CHARS ? stderrRaw.slice(-STDERR_LOG_MAX_CHARS) : stderrRaw
fs.writeFileSync(`docs-sync-out/kilo-stderr-${safe}.log`, body)
Comment thread
iscekic marked this conversation as resolved.
Resolved
} catch (err) {
console.warn(`${label}: failed to write kilo-stderr log: ${err.message}`)
}

if (result.error && !timedOut) {
console.warn(`${label}: spawn error: ${result.error.message}`)
}
Expand Down
203 changes: 202 additions & 1 deletion .github/docs-sync/selftest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ function writeExecutable(filePath, body) {
function makeStubKiloDir({ mode, callLog, stderrText = "event stream disconnected" }) {
const dir = mktemp("docs-sync-kilo-")
const kiloPath = path.join(dir, "kilo")
// mode: "stderr-exit0" | "record" | "partial-triage" | "mixed-triage"
// mode: "stderr-exit0" | "record" | "partial-triage" | "mixed-triage" | "write-edit-summary"
const script = `#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
Expand All @@ -81,6 +81,22 @@ let chunk = [];
if (fileArg && fs.existsSync(fileArg)) {
try { chunk = JSON.parse(fs.readFileSync(fileArg, "utf8")); } catch { chunk = []; }
}
if (mode === "write-edit-summary") {
// Success path: write the batch summary so edit.mjs returns true, while still
// emitting stderr so selftest can assert runKilo persisted it unconditionally.
process.stderr.write(stderrText + "\\n");
const m = fileArg && String(fileArg).match(/edit-batch-(\\d+)\\.json/);
const index = m ? m[1] : "0";
const summary = chunk.map((d) => ({
pr: d.number,
url: d.url,
action: "skipped",
reason: "selftest stub",
}));
fs.mkdirSync("docs-sync-out", { recursive: true });
fs.writeFileSync("docs-sync-out/edit-summary-" + index + ".json", JSON.stringify(summary));
process.exit(0);
}
if (mode === "partial-triage") {
// Classify only a proper subset (first URL) of the chunk.
const owned = chunk.slice(0, Math.max(0, chunk.length - 1));
Expand Down Expand Up @@ -372,6 +388,189 @@ function case2_defectB() {
}
}

// ---------------------------------------------------------------------------
// Case 2b — AC4a: every docs-sync kilo run argv carries --auto
// ---------------------------------------------------------------------------
/** Slice `args: [` … matching `]` from source (newlines allowed inside). */
function extractArgsArraySlice(source) {
const start = source.indexOf("args: [")
assert.ok(start >= 0, "args: [ not found in source")
let i = start + "args: ".length
assert.equal(source[i], "[")
let depth = 0
for (; i < source.length; i++) {
const ch = source[i]
if (ch === "[") depth++
else if (ch === "]") {
depth--
if (depth === 0) return source.slice(start, i + 1)
}
}
throw new assert.AssertionError({ message: "unclosed args: [ array in source" })
}

/** Label → kilo-stderr filename rule (must match lib.mjs runKilo). */
function kiloStderrLogName(label) {
return `kilo-stderr-${String(label).replace(/[^A-Za-z0-9._-]/g, "-")}.log`
}

function case2b_autoFlag() {
console.log("case 2b: AC4a (--auto on every docs-sync kilo run)")

// (i) region-scoped static check on triage.mjs / edit.mjs argv arrays
for (const name of ["triage.mjs", "edit.mjs"]) {
const src = fs.readFileSync(path.join(HERE, name), "utf8")
const slice = extractArgsArraySlice(src)
assert.ok(
slice.includes('"--auto"'),
`${name} args array must contain "--auto"; got:\n${slice}`,
)
}

// (ii) Fix verify failures step: join the run: | block and require --auto on kilo run
{
const yml = fs.readFileSync(path.join(HERE, "..", "workflows", "docs-sync.yml"), "utf8")
const stepIdx = yml.indexOf("Fix verify failures")
assert.ok(stepIdx >= 0, "Fix verify failures step missing")
const afterStep = yml.slice(stepIdx)
const runIdx = afterStep.indexOf("run: |")
assert.ok(runIdx >= 0, "run: | missing after Fix verify failures")
const blockStart = stepIdx + runIdx + "run: |".length
const rest = yml.slice(blockStart)
// Block ends at next unindented step key or EOF — collect indented lines
const lines = []
for (const line of rest.split("\n")) {
if (line === "") {
lines.push(line)
continue
}
// stop at next top-level list item under steps (two-space + "- ")
if (/^ {0,6}- name:/.test(line) || (/^\S/.test(line) && lines.length > 0)) break
lines.push(line)
}
// Join continuation backslashes then collapse whitespace for the kilo run line
const joined = lines
.map((l) => l.replace(/^\s+/, ""))
.join("\n")
.replace(/\\\n/g, " ")
.replace(/\s+/g, " ")
assert.match(joined, /kilo run\b/, `expected kilo run in Fix verify block:\n${joined}`)
const kiloCmd = joined.match(/kilo run\b[^|]*/)?.[0] ?? ""
assert.ok(
/\s--auto\b/.test(kiloCmd) || /kilo run\s+--auto\b/.test(kiloCmd),
`Fix verify kilo run must contain --auto; got: ${kiloCmd}`,
)
}

// (iii) authoritative: real stub invocations with callLog — every argv has --auto
{
const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n))
const worthy = prs
const triage = prs.map((p) => ({
pr: p.number,
url: p.url,
docs_worthy: true,
reason: "needs docs",
target_sections: ["overview"],
priority: "high",
}))
const cwd = setupEditCwd(worthy, triage)
const callLog = path.join(cwd, "kilo-calls.log")
const stderrText = "event stream disconnected DIAG-AUTO"
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText, callLog })

const result = runNodeScript(EDIT_SCRIPT, {
cwd,
kiloDir,
env: {
EDIT_MODEL: "test/model",
DOCS_SYNC_BACKOFF_MS: "0",
EDIT_BUDGET_MINUTES: "5",
EDIT_BATCH_TIMEOUT_MINUTES: "1",
},
})
assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`)

assert.ok(fs.existsSync(callLog), "callLog must be written (stub was invoked)")
const lines = fs.readFileSync(callLog, "utf8").trim().split("\n").filter(Boolean)
assert.ok(lines.length > 0, "callCount > 0 required (vacuous empty log forbidden)")
for (const line of lines) {
const { argv } = JSON.parse(line)
assert.ok(
Array.isArray(argv) && argv.includes("--auto"),
`every kilo argv must include --auto; got ${JSON.stringify(argv)}`,
)
}
}
}

// ---------------------------------------------------------------------------
// Case 2c — full child stderr always written (success and failure paths)
// ---------------------------------------------------------------------------
function case2c_stderrLogAlways() {
console.log("case 2c: unconditional kilo-stderr-*.log")

const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n))
const worthy = prs
const triage = prs.map((p) => ({
pr: p.number,
url: p.url,
docs_worthy: true,
reason: "needs docs",
target_sections: ["overview"],
priority: "high",
}))

// Failure path: stub exits 0 without summary (same mode as case 2)
{
const cwd = setupEditCwd(worthy, triage)
const stderrText = "FAILPATH-STDERR-MARKER"
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText })
const result = runNodeScript(EDIT_SCRIPT, {
cwd,
kiloDir,
env: {
EDIT_MODEL: "test/model",
DOCS_SYNC_BACKOFF_MS: "0",
EDIT_BUDGET_MINUTES: "5",
EDIT_BATCH_TIMEOUT_MINUTES: "1",
},
})
assert.equal(result.status, 0, result.output)
const logName = kiloStderrLogName("edit batch 0 attempt 1")
const logPath = path.join(cwd, "docs-sync-out", logName)
assert.equal(logName, "kilo-stderr-edit-batch-0-attempt-1.log")
assert.ok(fs.existsSync(logPath), `expected ${logPath} on failure path`)
assert.match(fs.readFileSync(logPath, "utf8"), /FAILPATH-STDERR-MARKER/)
}

// Success path: stub writes summary (today's path that discarded stderr)
{
const cwd = setupEditCwd(worthy, triage)
const stderrText = "SUCCESSPATH-STDERR-MARKER"
const kiloDir = makeStubKiloDir({ mode: "write-edit-summary", stderrText })
const result = runNodeScript(EDIT_SCRIPT, {
cwd,
kiloDir,
env: {
EDIT_MODEL: "test/model",
DOCS_SYNC_BACKOFF_MS: "0",
EDIT_BUDGET_MINUTES: "5",
EDIT_BATCH_TIMEOUT_MINUTES: "1",
},
})
assert.equal(result.status, 0, result.output)
assert.ok(
fs.existsSync(path.join(cwd, "docs-sync-out", "edit-summary-0.json")),
"stub must write summary (success path)",
)
const logName = kiloStderrLogName("edit batch 0 attempt 1")
const logPath = path.join(cwd, "docs-sync-out", logName)
assert.ok(fs.existsSync(logPath), `expected ${logPath} on success path`)
assert.match(fs.readFileSync(logPath, "utf8"), /SUCCESSPATH-STDERR-MARKER/)
}
}

// ---------------------------------------------------------------------------
// Case 3 — watermark invariant
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -934,6 +1133,8 @@ function main() {
const cases = [
case1_mergeOrFallback,
case2_defectB,
case2b_autoFlag,
case2c_stderrLogAlways,
case3_watermark,
case4_routing,
case5_recollection,
Expand Down
5 changes: 4 additions & 1 deletion .github/docs-sync/triage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,11 @@ function triageChunk(chunk, index, budgetDeadline) {
break
}

// Headless `kilo run` auto-rejects every permission ask; the runner has no
// user config granting bash, so without --auto the agent cannot run ordinary
// shell commands against the repository.
const result = runKilo({
args: ["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile],
args: ["run", "--auto", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile],
Comment thread
iscekic marked this conversation as resolved.
Resolved
timeoutMs: Math.min(CHUNK_TIMEOUT_MS, left),
streamStdout: false,
label: `triage chunk ${index} attempt ${attempt}`,
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/docs-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,10 @@ jobs:
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }}
run: |
set -o pipefail
kilo run "The docs build or tests failed. Read the attached docs-sync-out/verify.log and fix the packages/kilo-docs changes so they pass. Do not revert doc edits; fix them. Do not modify anything outside packages/kilo-docs." \
# Headless kilo run auto-rejects every permission ask; the runner has no
# user config granting bash, so without --auto the agent cannot run ordinary
# shell commands against the repository.
kilo run --auto "The docs build or tests failed. Read the attached docs-sync-out/verify.log and fix the packages/kilo-docs changes so they pass. Do not revert doc edits; fix them. Do not modify anything outside packages/kilo-docs." \
-m "$EDIT_MODEL" --dir "$GITHUB_WORKSPACE" -f docs-sync-out/verify.log \
| tee -a docs-sync-out/edit-log.txt
{ bun run --filter @kilocode/kilo-docs build && bun run --filter @kilocode/kilo-docs test; } 2>&1 | tee docs-sync-out/verify2.log
Expand Down
15 changes: 14 additions & 1 deletion packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,7 @@ export const RunCommand = effectCmd({
const MAX_RETRIES = 3 // kilocode_change
let retries = 0 // kilocode_change
let error: string | undefined
let autoRejected = false // kilocode_change - plain headless auto-reject must fail the run

// kilocode_change start - revert to upstream: consume native events without normalizing sync copies
for await (const event of events.stream) {
Expand Down Expand Up @@ -816,8 +817,10 @@ export const RunCommand = effectCmd({
err = String(props.error.data.message)
}
error = error ? error + EOL + err : err
if (emit("error", { error: props.error })) continue
// kilocode_change start - stderr first so --format json still surfaces the diagnostic
UI.error(err)
emit("error", { error: props.error })
// kilocode_change end
}

// kilocode_change start - reset retry budget only after resumed work becomes busy
Expand Down Expand Up @@ -868,6 +871,7 @@ export const RunCommand = effectCmd({
UI.Style.TEXT_NORMAL +
`subagent permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
)
autoRejected = true // kilocode_change
await client.permission.reply({
requestID: permission.id,
reply: "reject",
Expand All @@ -889,6 +893,7 @@ export const RunCommand = effectCmd({
UI.Style.TEXT_NORMAL +
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
)
autoRejected = true // kilocode_change
await client.permission.reply({
requestID: permission.id,
reply: "reject",
Expand All @@ -915,6 +920,14 @@ export const RunCommand = effectCmd({
}
// kilocode_change end
}
// kilocode_change start - idle must not clear an auto-rejected headless run
if (autoRejected) {
const msg = "run ended with an auto-rejected permission; pass --auto for autonomous use"
error = error ? error + EOL + msg : msg
UI.error(msg)
emit("error", { error: msg })
}
// kilocode_change end
return error
}
const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root)
Expand Down
Loading
Loading