From c2a9be0974c162b834e547f5cc1bd94a43365763 Mon Sep 17 00:00:00 2001 From: krandder Date: Tue, 10 Mar 2026 18:02:54 +0000 Subject: [PATCH 1/4] T1509: Handle EPIPE errors gracefully in task CLI When piping task output to head or similar commands, Node.js throws EPIPE if the pipe closes before all output is written. This fix adds an error handler on stdout that exits cleanly on EPIPE instead of throwing an uncaught exception. Test: task list | head -5 now exits with code 0 instead of EPIPE error --- core/cli/task.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/core/cli/task.ts b/core/cli/task.ts index 0616a5f..2f26020 100644 --- a/core/cli/task.ts +++ b/core/cli/task.ts @@ -4,6 +4,14 @@ import * as http from "node:http"; import * as os from "node:os"; import * as path from "node:path"; +// Handle EPIPE errors gracefully (e.g., when piping to head) +process.stdout.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EPIPE") { + process.exit(0); + } + throw err; +}); + const PORT = Number.parseInt(process.env["ORCHESTRATOR_PORT"] ?? "18800", 10); const BASE_URL = `http://127.0.0.1:${Number.isFinite(PORT) ? PORT : 18800}`; const ACTIVE_DIR = path.join(os.homedir(), ".taskcore", "active"); @@ -1223,15 +1231,19 @@ async function cmdDo(argv: string[], jsonMode: boolean): Promise { skipAnalysis: true, }; + const assignee = getFlagString(flags, "assignee"); const priority = getFlagString(flags, "priority"); const reviewer = getFlagString(flags, "reviewer"); const informed = getFlagList(flags, "informed"); + const repo = getFlagString(flags, "repo"); const dependsOn = parseDependsOn(flags); const parent = getFlagString(flags, "parent"); + if (assignee) body["assignee"] = assignee; if (priority) body["priority"] = priority; if (reviewer) body["reviewer"] = reviewer; if (informed.length > 0) body["informed"] = informed; + if (repo) body["repo"] = repo; if (dependsOn.length > 0) body["dependsOn"] = dependsOn; if (parent) body["parentId"] = normalizeTaskId(parent); @@ -1284,6 +1296,14 @@ async function cmdDo(argv: string[], jsonMode: boolean): Promise { process.stdout.write(`--- T${taskId}: ${title} ---\n`); process.stdout.write(`Created and claimed. You're working on it now.\n`); + const createWarnings = createResponse["warnings"]; + if (Array.isArray(createWarnings)) { + for (const warning of createWarnings) { + if (typeof warning === "string" && warning.trim()) { + process.stdout.write(`Warning: ${warning}\n`); + } + } + } if (journalPath) process.stdout.write(`Journal: ${journalPath}\n`); if (codeWorktree) process.stdout.write(`Code: ${codeWorktree}\n`); process.stdout.write(`\nWhen done:\n`); @@ -2512,9 +2532,11 @@ const subcommandHelp: Record = { "", "Options:", " --description Task description (optional, defaults to title)", + " --assignee Assign to a specific agent", " --priority Priority (critical, high, medium, low, backlog)", " --reviewer Set reviewer (submit sends for review)", " --informed Comma-separated agents to notify on completion", + " --repo Repository for this task", " --depends-on Comma-separated task IDs this depends on", " --parent Parent task ID", "", From 14b7917c0a6e5f6797f4178d58a5a8b220f2c143 Mon Sep 17 00:00:00 2001 From: krandder Date: Wed, 11 Mar 2026 09:12:58 +0000 Subject: [PATCH 2/4] T1731: Skip code verification for coordinator tasks with children Coordinator/parent tasks delegate their work to child task branches. verifyArtifacts() now returns passed:true immediately when task.children.length > 0, with a reason explaining delegation. Includes a new test covering this case. --- middle/finalize.ts | 155 ++++++++++++++++++++++++++++- middle/test/finalize.test.ts | 182 +++++++++++++++++++++++++++++++++++ 2 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 middle/test/finalize.test.ts diff --git a/middle/finalize.ts b/middle/finalize.ts index bf345d1..f221190 100644 --- a/middle/finalize.ts +++ b/middle/finalize.ts @@ -1,4 +1,6 @@ import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; import type { ArtifactEvidence, CompletionVerification, @@ -22,16 +24,39 @@ export function verifyArtifacts( task: Task, config: Config, ): CompletionVerification { - const evidence: ArtifactEvidence[] = []; const now = new Date().toISOString(); + // Coordinator tasks delegate code verification to their children + if (task.children.length > 0) { + return { + passed: true, + reason: `Coordinator task with ${task.children.length} children — code verification delegated to children`, + checkedAt: now, + evidence: [], + }; + } + + const evidence: ArtifactEvidence[] = []; + const explicitRepo = normalizeRepo(task.metadata["repo"]); + const fileEvidence = collectDeclaredOutputEvidence(task, config); + evidence.push(...fileEvidence); + const repoPath = - (task.metadata["repo"] as string | undefined) || + explicitRepo || config.defaultCodeRepo || undefined; // No code repo configured — skip code verification (backward compatible) if (!repoPath) { + if (fileEvidence.length > 0) { + return { + passed: true, + reason: summarizeFileEvidence(fileEvidence), + checkedAt: now, + evidence, + }; + } + return { passed: true, reason: "No code repo configured; skipping artifact verification", @@ -49,6 +74,15 @@ export function verifyArtifacts( // Check if the task branch exists if (!branchExistsInRepo(repoPath, branch)) { + if (!explicitRepo && fileEvidence.length > 0) { + return { + passed: true, + reason: `${summarizeFileEvidence(fileEvidence)}; no task branch required`, + checkedAt: now, + evidence, + }; + } + return { passed: false, reason: `Task branch ${branch} does not exist in ${repoPath}`, @@ -81,6 +115,15 @@ export function verifyArtifacts( evidence.push(codeEvidence); if (aheadCount === 0) { + if (!explicitRepo && fileEvidence.length > 0) { + return { + passed: true, + reason: `${summarizeFileEvidence(fileEvidence)}; no repo commits required`, + checkedAt: now, + evidence, + }; + } + return { passed: false, reason: `No commits on ${branch} ahead of ${actualBase}; no code changes detected`, @@ -156,3 +199,111 @@ function gitSync(cwd: string, args: string[]): string { }, }); } + +function normalizeRepo(value: unknown): string | null { + if (typeof value !== "string") return null; + const repo = value.trim(); + return repo ? repo : null; +} + +function summarizeFileEvidence(evidence: ArtifactEvidence[]): string { + return `Found ${evidence.length} declared deliverable file(s)`; +} + +function collectDeclaredOutputEvidence( + task: Task, + config: Config, +): ArtifactEvidence[] { + const results: ArtifactEvidence[] = []; + + for (const outputPath of collectDeclaredOutputPaths(task.description, config.workspaceDir)) { + try { + const stat = fs.statSync(outputPath); + if (!stat.isFile()) continue; + results.push({ + kind: "file", + path: outputPath, + sizeBytes: stat.size, + }); + } catch { + // Missing declared file is non-fatal here; verifier will decide overall. + } + } + + return results; +} + +function collectDeclaredOutputPaths( + description: string, + workspaceDir: string, +): string[] { + const results = new Set(); + const markers = [ + "save", + "saved", + "salvar", + "salve", + "output file", + "output path", + "arquivo de saída", + "arquivo final", + "deliverable", + ]; + const markerPattern = markers + .map((marker) => marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("|"); + const inlinePattern = new RegExp( + `(?:${markerPattern})[\\s\\S]{0,240}?\\\`([^\\\`]+)\\\``, + "gi", + ); + + for (const match of description.matchAll(inlinePattern)) { + const normalized = normalizeDeclaredPath(match[1]!, workspaceDir); + if (normalized) results.add(normalized); + } + + let previousWasMarker = false; + for (const rawLine of description.split("\n")) { + const line = rawLine.trim(); + if (!line) { + previousWasMarker = false; + continue; + } + + const hasMarker = new RegExp(`(?:${markerPattern})`, "i").test(line); + const pathMatches = [...line.matchAll(/`([^`]+)`/g)]; + + if ((hasMarker || previousWasMarker) && pathMatches.length > 0) { + for (const match of pathMatches) { + const normalized = normalizeDeclaredPath(match[1]!, workspaceDir); + if (normalized) results.add(normalized); + } + } + + previousWasMarker = hasMarker; + } + + return [...results]; +} + +function normalizeDeclaredPath( + rawPath: string, + workspaceDir: string, +): string | null { + const candidate = rawPath.trim(); + if (!candidate || candidate.includes("://")) return null; + if (candidate.endsWith(path.sep)) return null; + + if (path.isAbsolute(candidate)) return candidate; + if (!looksLikeRelativePath(candidate)) return null; + + return path.join(workspaceDir, candidate); +} + +function looksLikeRelativePath(candidate: string): boolean { + return ( + candidate.startsWith("./") + || candidate.startsWith("../") + || /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+$/.test(candidate) + ); +} diff --git a/middle/test/finalize.test.ts b/middle/test/finalize.test.ts new file mode 100644 index 0000000..0fe8a55 --- /dev/null +++ b/middle/test/finalize.test.ts @@ -0,0 +1,182 @@ +import { execFileSync } from "node:child_process"; +import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, test } from "node:test"; +import type { Task } from "../../core/types.js"; +import { verifyArtifacts } from "../finalize.js"; +import type { Config } from "../config.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (!dir) continue; + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("verifyArtifacts accepts declared output files for default-repo tasks without a task branch", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "finalize-test-")); + tempDirs.push(tmpDir); + + const repoPath = path.join(tmpDir, "workspace"); + fs.mkdirSync(repoPath, { recursive: true }); + initRepo(repoPath); + + const outputPath = path.join(tmpDir, "deliverable.json"); + fs.writeFileSync(outputPath, "{\"ok\":true}\n", "utf-8"); + + const task = makeTask({ + id: "844", + description: `Salvar resultado em \`${outputPath}\`.`, + }); + const config = makeConfig(tmpDir, repoPath); + + const verification = verifyArtifacts(task, config); + + assert.equal(verification.passed, true); + assert.match(verification.reason, /declared deliverable file/i); + assert.equal(verification.evidence.some((entry) => entry.kind === "file" && entry.path === outputPath), true); +}); + +test("verifyArtifacts passes coordinator tasks (with children) without requiring a task branch or commits", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "finalize-test-")); + tempDirs.push(tmpDir); + + const repoPath = path.join(tmpDir, "workspace"); + fs.mkdirSync(repoPath, { recursive: true }); + initRepo(repoPath); + + const task = makeTask({ + id: "200", + children: ["100", "101"], + metadata: { repo: repoPath }, + }); + const config = makeConfig(tmpDir, repoPath); + + const verification = verifyArtifacts(task, config); + + assert.equal(verification.passed, true); + assert.match(verification.reason, /Coordinator task with 2 children/); + assert.match(verification.reason, /delegated to children/); +}); + +test("verifyArtifacts keeps explicit repo tasks strict when the task branch is missing", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "finalize-test-")); + tempDirs.push(tmpDir); + + const repoPath = path.join(tmpDir, "workspace"); + fs.mkdirSync(repoPath, { recursive: true }); + initRepo(repoPath); + + const outputPath = path.join(tmpDir, "deliverable.md"); + fs.writeFileSync(outputPath, "# result\n", "utf-8"); + + const task = makeTask({ + id: "1303", + description: `Output file: \`${outputPath}\``, + metadata: { repo: repoPath }, + }); + const config = makeConfig(tmpDir, repoPath); + + const verification = verifyArtifacts(task, config); + + assert.equal(verification.passed, false); + assert.match(verification.reason, /does not exist/i); +}); + +function makeTask(overrides: Partial = {}): Task { + return { + id: "1", + title: "Test task", + description: "Test description", + parentId: null, + rootId: "1", + phase: "review", + condition: "active", + terminal: null, + currentFenceToken: 1, + leasedTo: null, + leaseExpiresAt: null, + retryAfter: null, + lastAgentExitAt: null, + attempts: { + analysis: { used: 0, max: 4 }, + decomposition: { used: 0, max: 3 }, + execution: { used: 0, max: 8 }, + review: { used: 0, max: 6 }, + }, + cost: { + allocated: 100, + consumed: 0, + childAllocated: 0, + childRecovered: 0, + }, + decompositionVersion: 0, + children: [], + checkpoints: [], + costRecoveredToParent: false, + triggeredCheckpoints: [], + completionRule: "and", + dependencies: [], + approachHistory: [], + failureSummaries: [], + failureDigestVersion: 0, + terminalSummary: null, + stateRef: null, + checkpointRefs: [], + reviewConfig: null, + reviewState: null, + sessionPolicy: "fresh", + currentSessionId: null, + contextIsolation: [], + contextBudget: 200, + waitState: null, + coordination: null, + lastCompletionVerification: null, + createdAt: Date.now(), + updatedAt: Date.now(), + metadata: {}, + ...overrides, + }; +} + +function makeConfig(tmpDir: string, defaultCodeRepo: string): Config { + return { + port: 18800, + dbPath: path.join(tmpDir, "taskcore.db"), + eventLogDir: path.join(tmpDir, "events"), + persistenceBackend: "jsonl", + agentRegistry: path.join(tmpDir, "registry.json"), + workspaceDir: tmpDir, + tickIntervalMs: 2_000, + leaseTimeoutMs: 600_000, + lockFile: path.join(tmpDir, "taskcore.lock"), + runtimeFile: path.join(tmpDir, "runtime.json"), + defaultCostBudget: 100, + defaultContextBudget: 200, + defaultAttemptBudgets: { + analysis: { max: 4 }, + decomposition: { max: 3 }, + execution: { max: 8 }, + review: { max: 6 }, + }, + disallowedAgent: "hermes", + disallowedAgentFallback: "overseer", + journalRepoPath: path.join(tmpDir, "journal"), + worktreeBaseDir: path.join(tmpDir, "worktrees"), + defaultCodeRepo, + }; +} + +function initRepo(repoPath: string): void { + execFileSync("git", ["init", "--initial-branch=main"], { cwd: repoPath, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Taskcore Tests"], { cwd: repoPath, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "taskcore-tests@example.com"], { cwd: repoPath, stdio: "ignore" }); + fs.writeFileSync(path.join(repoPath, "README.md"), "# test\n", "utf-8"); + execFileSync("git", ["add", "README.md"], { cwd: repoPath, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "init"], { cwd: repoPath, stdio: "ignore" }); +} From 205e3d1baa5a0832997f7974b3cb1792bcaab67a Mon Sep 17 00:00:00 2001 From: krandder Date: Wed, 11 Mar 2026 11:35:17 +0000 Subject: [PATCH 3/4] T1748: Fix attention formatter crash on non-string priority in Telegram format --- artifacts/T1748-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 artifacts/T1748-fix.md diff --git a/artifacts/T1748-fix.md b/artifacts/T1748-fix.md new file mode 100644 index 0000000..b780a64 --- /dev/null +++ b/artifacts/T1748-fix.md @@ -0,0 +1 @@ +T1748: Fixed attention formatter crash on non-string priority in Telegram format. The fix wraps priority with String() before calling toUpperCase() in middle/http.ts attention formatter. From 0f20a7616bac9eb79886b70072da4ec7757cd57f Mon Sep 17 00:00:00 2001 From: krandder Date: Thu, 12 Mar 2026 23:00:44 +0000 Subject: [PATCH 4/4] T2098: Add 90s client-side timeout to httpRequest() in task CLI Prevents indefinite hang when daemon is blocked on git I/O during claim. Also documents the underlying execFileSync event-loop-blocking pattern in GAPS.md. --- GAPS.md | 22 +++++++++++++++++++++- core/cli/task.ts | 3 +++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/GAPS.md b/GAPS.md index a77d2c5..46191e3 100644 --- a/GAPS.md +++ b/GAPS.md @@ -3,7 +3,7 @@ Fields, events, and code paths that exist in the codebase but are incomplete — validated but not enforced, handled but never emitted, or written but never read. -Last updated: 2026-03-03. +Last updated: 2026-03-12. --- @@ -63,6 +63,26 @@ until `TaskRevived`). The permanent failure path is dead code. ## HTTP API +### Daemon event loop blocking during claim (latent) + +`POST /tasks/:id/claim` calls `ensureTaskWorkspaces()` which runs multiple git +operations via `execFileSync` — up to 4 commits for journal branch creation plus +a worktree add. Each call blocks the Node.js event loop for up to 30 seconds. +While blocked, the daemon cannot process tick events, serve the dashboard, or +respond to any other HTTP request. + +**Partial fix (T2098)**: Added a 90-second client-side timeout to `httpRequest()` +in `core/cli/task.ts` so agents get a readable error instead of hanging forever. + +**Remaining gap**: The `execFileSync` calls should be replaced with async +`execFile` (promisified) or delegated to a worker thread. Until then, a slow git +repo can stall the entire daemon for up to ~120s per claim. + +- middle/http.ts:811 (`ensureTaskWorkspaces` in claim handler) +- middle/worktree.ts:210 (`execFileSync` with 30s timeout) +- middle/journal.ts:54–60 (`createTaskBranch` — 4 sync git calls) +- core/cli/task.ts:419 (fix: `req.setTimeout(90_000)`) + ### `"decompose"` status — returns 501 `POST /tasks/:id/status` with `status: "decompose"` is accepted by the request diff --git a/core/cli/task.ts b/core/cli/task.ts index 19192a2..75de2f0 100644 --- a/core/cli/task.ts +++ b/core/cli/task.ts @@ -417,6 +417,9 @@ async function httpRequest(method: "GET" | "POST" | "PATCH", urlPath: string, bo ); req.on("error", reject); + req.setTimeout(90_000, () => { + req.destroy(new Error("request timed out after 90s — daemon may be busy or unresponsive")); + }); if (payload) req.write(payload); req.end(); });