fix(athena): tie prompt-file lifecycle to council_finalize

Remove blind 30-min setTimeout cleanup from prepare_council_prompt. Prompt
files now stay until council_finalize archives them in a try/finally block.
Extract shared resolvePromptTempFilePath helper used by both movePromptFile
and the new cleanupPromptFile safety net.

Co-authored-by: Vacbo <2445>
This commit is contained in:
ismeth
2026-03-14 15:32:33 +01:00
committed by YeonGyu-Kim
parent 69bb6eda8a
commit 77d929a3ab
3 changed files with 137 additions and 116 deletions
@@ -27,6 +27,33 @@ export function isPathEscaping(expectedRoot: string, targetPath: string): boolea
return rel === ".." || rel.startsWith("../") || rel.startsWith("..\\") || isAbsolute(rel)
}
function resolvePromptTempFilePath(promptFilePath: string, base: string): string | undefined {
const absPromptPath = isAbsolute(promptFilePath) ? promptFilePath : resolve(base, promptFilePath)
const expectedPromptRoot = join(base, ".sisyphus", "tmp")
if (isPathEscaping(expectedPromptRoot, absPromptPath)) {
log("[council-finalize] Rejected prompt_file outside .sisyphus/tmp/", { promptFile: promptFilePath })
return undefined
}
return absPromptPath
}
export async function cleanupPromptFile(promptFilePath: string, base: string): Promise<void> {
const absPromptPath = resolvePromptTempFilePath(promptFilePath, base)
if (!absPromptPath) return
try {
await unlink(absPromptPath)
log("[council-finalize] Cleaned up prompt temp file", { promptFile: promptFilePath })
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code !== "ENOENT") {
log("[council-finalize] Failed to clean up prompt temp file", { promptFile: promptFilePath, error: String(err), code })
}
}
}
export async function movePromptFile(
promptFilePath: string,
base: string,
@@ -35,12 +62,8 @@ export async function movePromptFile(
): Promise<string | undefined> {
try {
const promptFilename = "council-prompt.md"
const absPromptSrc = isAbsolute(promptFilePath) ? promptFilePath : resolve(base, promptFilePath)
const expectedPromptRoot = join(base, ".sisyphus", "tmp")
if (isPathEscaping(expectedPromptRoot, absPromptSrc)) {
log("[council-finalize] Rejected prompt_file outside .sisyphus/tmp/", { promptFile: promptFilePath })
return undefined
}
const absPromptSrc = resolvePromptTempFilePath(promptFilePath, base)
if (!absPromptSrc) return undefined
const absPromptDest = join(absArchiveDir, promptFilename)
await rename(absPromptSrc, absPromptDest).catch(async (renameErr) => {
log("[council-finalize] Rename failed, falling back to copy", { promptFile: promptFilePath, error: String(renameErr) })
@@ -3,7 +3,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises"
import { join } from "node:path"
import { randomBytes } from "node:crypto"
import { extractCouncilResponse } from "./council-response-extractor"
import { TASK_ID_PATTERN, slugify, toPosixPath, extractAgentFromFrontmatter, isPathEscaping, movePromptFile } from "./council-finalize-helpers"
import { TASK_ID_PATTERN, slugify, toPosixPath, extractAgentFromFrontmatter, isPathEscaping, movePromptFile, cleanupPromptFile } from "./council-finalize-helpers"
import { formatMetaYaml, type MetaMember } from "./meta-yaml-formatter"
import {
buildAthenaRuntimeGuidance,
@@ -43,125 +43,131 @@ export function createCouncilFinalize(
}
const base = basePath ?? process.cwd()
const hexId = randomBytes(COUNCIL_DEFAULTS.ARCHIVE_ID_BYTES).toString("hex")
const safeName = slugify(args.name) || "unnamed"
const archiveName = `council-${safeName}-${hexId}`
const relArchiveDir = join(".sisyphus", "athena", archiveName)
const relArchiveDirForOutput = toPosixPath(relArchiveDir)
const absArchiveDir = join(base, relArchiveDir)
if (isPathEscaping(join(base, ".sisyphus", "athena"), absArchiveDir)) {
return `Security error: archive directory would escape .sisyphus/athena/`
}
try {
const hexId = randomBytes(COUNCIL_DEFAULTS.ARCHIVE_ID_BYTES).toString("hex")
const safeName = slugify(args.name) || "unnamed"
const archiveName = `council-${safeName}-${hexId}`
const relArchiveDir = join(".sisyphus", "athena", archiveName)
const relArchiveDirForOutput = toPosixPath(relArchiveDir)
const absArchiveDir = join(base, relArchiveDir)
await mkdir(absArchiveDir, { recursive: true })
const members: CouncilMemberResult[] = []
const metaMembers: MetaMember[] = []
for (const taskId of args.task_ids) {
if (!TASK_ID_PATTERN.test(taskId)) {
members.push({
task_id: taskId,
member: "unknown",
has_response: false,
error: "Invalid task ID: contains unsafe characters",
})
metaMembers.push({
task_id: taskId,
member: "unknown",
member_slug: "unknown",
task_output_path: "",
archive_file: "",
has_response: false,
response_complete: false,
})
continue
if (isPathEscaping(join(base, ".sisyphus", "athena"), absArchiveDir)) {
return `Security error: archive directory would escape .sisyphus/athena/`
}
// Task ID is pre-validated by TASK_ID_PATTERN — path escaping is impossible
const relTaskOutput = join(".sisyphus", "task-outputs", `${taskId}.md`)
const relTaskOutputForOutput = toPosixPath(relTaskOutput)
const absTaskOutput = join(base, relTaskOutput)
await mkdir(absArchiveDir, { recursive: true })
const members: CouncilMemberResult[] = []
const metaMembers: MetaMember[] = []
let fileContent: string
try {
fileContent = await readFile(absTaskOutput, "utf-8")
} catch {
members.push({
for (const taskId of args.task_ids) {
if (!TASK_ID_PATTERN.test(taskId)) {
members.push({
task_id: taskId,
member: "unknown",
has_response: false,
error: "Invalid task ID: contains unsafe characters",
})
metaMembers.push({
task_id: taskId,
member: "unknown",
member_slug: "unknown",
task_output_path: "",
archive_file: "",
has_response: false,
response_complete: false,
})
continue
}
// Task ID is pre-validated by TASK_ID_PATTERN - path escaping is impossible
const relTaskOutput = join(".sisyphus", "task-outputs", `${taskId}.md`)
const relTaskOutputForOutput = toPosixPath(relTaskOutput)
const absTaskOutput = join(base, relTaskOutput)
let fileContent: string
try {
fileContent = await readFile(absTaskOutput, "utf-8")
} catch {
members.push({
task_id: taskId,
member: "unknown",
has_response: false,
error: "Task output file not found",
})
metaMembers.push({
task_id: taskId,
member: "unknown",
member_slug: "unknown",
task_output_path: relTaskOutputForOutput,
archive_file: "",
has_response: false,
response_complete: false,
})
continue
}
const agentName = extractAgentFromFrontmatter(fileContent) ?? "unknown"
const memberSlug = slugify(agentName) || "unknown"
const taskSlug = slugify(taskId) || taskId.replace(/[^a-zA-Z0-9_-]+/g, "-")
const extraction = extractCouncilResponse(fileContent)
const relArchiveFile = join(relArchiveDir, `${memberSlug}-${taskSlug}.md`)
const relArchiveFileForOutput = toPosixPath(relArchiveFile)
const absArchiveFile = join(base, relArchiveFile)
const memberResult: CouncilMemberResult = {
task_id: taskId,
member: "unknown",
has_response: false,
error: "Task output file not found",
})
member: agentName,
has_response: extraction.has_response,
}
if (extraction.has_response) {
memberResult.response_complete = extraction.response_complete
}
if (extraction.result !== null) {
await writeFile(absArchiveFile, extraction.result, "utf-8")
memberResult.archive_file = relArchiveFileForOutput
}
members.push(memberResult)
metaMembers.push({
task_id: taskId,
member: "unknown",
member_slug: "unknown",
member: agentName,
member_slug: memberSlug,
task_output_path: relTaskOutputForOutput,
archive_file: "",
has_response: false,
response_complete: false,
archive_file: extraction.result !== null ? relArchiveFileForOutput : "",
has_response: extraction.has_response,
response_complete: extraction.response_complete,
})
continue
}
const agentName = extractAgentFromFrontmatter(fileContent) ?? "unknown"
const memberSlug = slugify(agentName) || "unknown"
const taskSlug = slugify(taskId) || taskId.replace(/[^a-zA-Z0-9_-]+/g, "-")
const extraction = extractCouncilResponse(fileContent)
const relPromptFile = args.prompt_file
? await movePromptFile(args.prompt_file, base, absArchiveDir, relArchiveDir)
: undefined
const relArchiveFile = join(relArchiveDir, `${memberSlug}-${taskSlug}.md`)
const relArchiveFileForOutput = toPosixPath(relArchiveFile)
const absArchiveFile = join(base, relArchiveFile)
const relMetaFile = join(relArchiveDir, "meta.yaml")
const relMetaFileForOutput = toPosixPath(relMetaFile)
const absMetaFile = join(base, relMetaFile)
const createdAt = new Date().toISOString()
await writeFile(absMetaFile, formatMetaYaml(archiveName, createdAt, metaMembers, args.question, relPromptFile), "utf-8")
const memberResult: CouncilMemberResult = {
task_id: taskId,
member: agentName,
has_response: extraction.has_response,
const result: CouncilFinalizeResult = {
archive_dir: relArchiveDirForOutput,
meta_file: relMetaFileForOutput,
members,
}
if (extraction.has_response) {
memberResult.response_complete = extraction.response_complete
const resolvedMode = (args.mode === "non-interactive" ? "non-interactive" : "interactive") as CouncilGuidanceMode
const guidance = buildAthenaRuntimeGuidance(resolvedIntent, resolvedMode)
return JSON.stringify(result, null, 2) + "\n\n" + guidance
} finally {
if (args.prompt_file) {
await cleanupPromptFile(args.prompt_file, base)
}
if (extraction.result !== null) {
await writeFile(absArchiveFile, extraction.result, "utf-8")
memberResult.archive_file = relArchiveFileForOutput
}
members.push(memberResult)
metaMembers.push({
task_id: taskId,
member: agentName,
member_slug: memberSlug,
task_output_path: relTaskOutputForOutput,
archive_file: extraction.result !== null ? relArchiveFileForOutput : "",
has_response: extraction.has_response,
response_complete: extraction.response_complete,
})
}
const relPromptFile = args.prompt_file
? await movePromptFile(args.prompt_file, base, absArchiveDir, relArchiveDir)
: undefined
const relMetaFile = join(relArchiveDir, "meta.yaml")
const relMetaFileForOutput = toPosixPath(relMetaFile)
const absMetaFile = join(base, relMetaFile)
const createdAt = new Date().toISOString()
await writeFile(absMetaFile, formatMetaYaml(archiveName, createdAt, metaMembers, args.question, relPromptFile), "utf-8")
const result: CouncilFinalizeResult = {
archive_dir: relArchiveDirForOutput,
meta_file: relMetaFileForOutput,
members,
}
const resolvedMode = (args.mode === "non-interactive" ? "non-interactive" : "interactive") as CouncilGuidanceMode
const guidance = buildAthenaRuntimeGuidance(resolvedIntent, resolvedMode)
return JSON.stringify(result, null, 2) + "\n\n" + guidance
},
})
}
+1 -9
View File
@@ -111,14 +111,6 @@ Returns the file path to reference in subsequent task() calls.`
await writeFile(filePath, content, "utf-8")
setTimeout(() => {
unlink(filePath).catch((err) => {
const code = (err as NodeJS.ErrnoException).code
if (code !== "ENOENT") {
log("[prepare-council-prompt] Failed to clean up temp file", { filePath, error: String(err) })
}
})
}, CLEANUP_DELAY_MS)
log("[prepare-council-prompt] Saved prompt", { filePath, length: args.prompt.length, mode })
@@ -127,7 +119,7 @@ Returns the file path to reference in subsequent task() calls.`
Use this path in each council member's task() call:
- prompt: "Read ${filePath} for your instructions."
The file auto-deletes after 30 minutes.`
The file stays in .sisyphus/tmp until council_finalize archives and cleans it up.`
} catch (err) {
return `Error saving council prompt: ${String(err)}`
}