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:
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user