From b8cb4132cb33c1c56d59fce3a9001f98e5922fd3 Mon Sep 17 00:00:00 2001 From: Janni Turunen Date: Fri, 20 Feb 2026 21:53:42 +0200 Subject: [PATCH 1/2] fix(taskctl): coerce numeric depends_on to strings in Composer schema (#250) --- packages/opencode/src/agent/agent.ts | 12 ++++- packages/opencode/src/tasks/composer.ts | 2 +- packages/opencode/test/tasks/composer.test.ts | 53 +++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index e643c0293b7b..0b952adf9c41 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -238,6 +238,15 @@ If the spec is clear enough to decompose: "labels": ["module:config", "file:src/config/config.ts"], "depends_on": [], "priority": 0 + }, + { + "title": "Write OAuth2 login handler", + "description": "Implement the login route using the config schema from the previous task", + "acceptance_criteria": "Handler validates token, returns 401 on failure. Tests pass.", + "task_type": "implementation", + "labels": ["module:auth", "file:src/auth/login.ts"], + "depends_on": ["Add OAuth2 config schema"], + "priority": 1 } ] } @@ -250,7 +259,8 @@ RULES FOR GOOD TASK DECOMPOSITION: 5. Tasks with no shared module:/file: labels can run in parallel 6. Do not create tasks for work not explicitly required by the issue 7. Validate your own output: check that no depends_on creates a cycle before responding -8. Respond with ONLY the JSON object — no markdown, no explanation, no code blocks`, +8. Respond with ONLY the JSON object — no markdown, no explanation, no code blocks +9. depends_on values must be exact task title strings — NOT numbers or indexes`, }, "developer-pipeline": { name: "developer-pipeline", diff --git a/packages/opencode/src/tasks/composer.ts b/packages/opencode/src/tasks/composer.ts index f07171ff9121..4c376c18e9ff 100644 --- a/packages/opencode/src/tasks/composer.ts +++ b/packages/opencode/src/tasks/composer.ts @@ -12,7 +12,7 @@ const ComposerTasksSchema = z.object({ acceptance_criteria: z.string().min(1), task_type: z.enum(["implementation", "test", "research"]), labels: z.array(z.string().max(100)).default([]), - depends_on: z.array(z.string().min(1).max(200)).default([]), + depends_on: z.array(z.union([z.string(), z.number()]).transform(String).pipe(z.string().min(1).max(200))).default([]), priority: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(4)]), }) diff --git a/packages/opencode/test/tasks/composer.test.ts b/packages/opencode/test/tasks/composer.test.ts index c794d93a8259..14c3027f63b5 100644 --- a/packages/opencode/test/tasks/composer.test.ts +++ b/packages/opencode/test/tasks/composer.test.ts @@ -852,4 +852,57 @@ test("runComposer rolls back tasks on partial creation failure", async () => { } Store.createTask = originalCreate +}) + +test("ComposerTasksSchema coerces numeric depends_on values to strings", async () => { + // This test verifies the fix for issue #250: numeric depends_on values are coerced to strings + // by the Zod schema using .transform(String).pipe(z.string()...) + + const taskWithNumericDeps = { + title: "Task with numeric deps", + description: "Test numeric dependency coercion", + acceptance_criteria: "Schema accepts and coerces numbers to strings", + task_type: "implementation" as const, + labels: ["module:test"], + depends_on: [1, 2, "existing-task"], + priority: 1, + } + + // Import ComposerTasksSchema by accessing it through composer module + const { default: z } = await import("zod") + const ComposerTasksSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().min(1), + acceptance_criteria: z.string().min(1), + task_type: z.enum(["implementation", "test", "research"]), + labels: z.array(z.string().max(100)).default([]), + depends_on: z.array(z.union([z.string(), z.number()]).transform(String).pipe(z.string().min(1).max(200))).default([]), + priority: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(4)]), + }) + + const result = ComposerTasksSchema.safeParse(taskWithNumericDeps) + + if (!result.success) { + throw new Error(`Schema validation failed: ${result.error.issues.map((i) => i.message).join(", ")}`) + } + + // Verify depends_on contains all strings now + const { depends_on } = result.data + if (!Array.isArray(depends_on)) { + throw new Error("Expected depends_on to be an array") + } + + if (depends_on.length !== 3) { + throw new Error(`Expected 3 dependencies, got ${depends_on.length}`) + } + + const hasNonStringDeps = depends_on.some((d) => typeof d !== "string") + if (hasNonStringDeps) { + throw new Error(`Expected all depends_on values to be strings, got: ${JSON.stringify(depends_on)}`) + } + + // Verify the values are coerced correctly + if (depends_on[0] !== "1" || depends_on[1] !== "2" || depends_on[2] !== "existing-task") { + throw new Error(`Expected ["1", "2", "existing-task"], got ${JSON.stringify(depends_on)}`) + } }) \ No newline at end of file From 336c67005818ac35511a762d19f89a35ad237c7e Mon Sep 17 00:00:00 2001 From: Janni Turunen Date: Fri, 20 Feb 2026 21:58:04 +0200 Subject: [PATCH 2/2] fix(taskctl): revert schema coercion, reject numeric depends_on, strengthen prompt rule (#250) --- packages/opencode/src/agent/agent.ts | 6 +- packages/opencode/src/tasks/composer.ts | 2 +- packages/opencode/test/tasks/composer.test.ts | 93 +++++++++---------- 3 files changed, 50 insertions(+), 51 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 0b952adf9c41..504e0c56fc9f 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -245,8 +245,8 @@ If the spec is clear enough to decompose: "acceptance_criteria": "Handler validates token, returns 401 on failure. Tests pass.", "task_type": "implementation", "labels": ["module:auth", "file:src/auth/login.ts"], - "depends_on": ["Add OAuth2 config schema"], - "priority": 1 + "depends_on": ["Add OAuth2 config schema"], // use exact title strings from this batch, never numeric indexes + "priority": 1 } ] } @@ -260,7 +260,7 @@ RULES FOR GOOD TASK DECOMPOSITION: 6. Do not create tasks for work not explicitly required by the issue 7. Validate your own output: check that no depends_on creates a cycle before responding 8. Respond with ONLY the JSON object — no markdown, no explanation, no code blocks -9. depends_on values must be exact task title strings — NOT numbers or indexes`, + 9. depends_on values must be the EXACT title string of another task in this batch — never use numbers, indexes, or abbreviations`, }, "developer-pipeline": { name: "developer-pipeline", diff --git a/packages/opencode/src/tasks/composer.ts b/packages/opencode/src/tasks/composer.ts index 4c376c18e9ff..f69b72882351 100644 --- a/packages/opencode/src/tasks/composer.ts +++ b/packages/opencode/src/tasks/composer.ts @@ -12,7 +12,7 @@ const ComposerTasksSchema = z.object({ acceptance_criteria: z.string().min(1), task_type: z.enum(["implementation", "test", "research"]), labels: z.array(z.string().max(100)).default([]), - depends_on: z.array(z.union([z.string(), z.number()]).transform(String).pipe(z.string().min(1).max(200))).default([]), + depends_on: z.array(z.string().min(1).max(200)).default([]), priority: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(4)]), }) diff --git a/packages/opencode/test/tasks/composer.test.ts b/packages/opencode/test/tasks/composer.test.ts index 14c3027f63b5..27c2608f264a 100644 --- a/packages/opencode/test/tasks/composer.test.ts +++ b/packages/opencode/test/tasks/composer.test.ts @@ -854,55 +854,54 @@ test("runComposer rolls back tasks on partial creation failure", async () => { Store.createTask = originalCreate }) -test("ComposerTasksSchema coerces numeric depends_on values to strings", async () => { - // This test verifies the fix for issue #250: numeric depends_on values are coerced to strings - // by the Zod schema using .transform(String).pipe(z.string()...) - - const taskWithNumericDeps = { - title: "Task with numeric deps", - description: "Test numeric dependency coercion", - acceptance_criteria: "Schema accepts and coerces numbers to strings", - task_type: "implementation" as const, - labels: ["module:test"], - depends_on: [1, 2, "existing-task"], - priority: 1, - } - - // Import ComposerTasksSchema by accessing it through composer module - const { default: z } = await import("zod") - const ComposerTasksSchema = z.object({ - title: z.string().min(1).max(200), - description: z.string().min(1), - acceptance_criteria: z.string().min(1), - task_type: z.enum(["implementation", "test", "research"]), - labels: z.array(z.string().max(100)).default([]), - depends_on: z.array(z.union([z.string(), z.number()]).transform(String).pipe(z.string().min(1).max(200))).default([]), - priority: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(4)]), - }) - - const result = ComposerTasksSchema.safeParse(taskWithNumericDeps) - - if (!result.success) { - throw new Error(`Schema validation failed: ${result.error.issues.map((i) => i.message).join(", ")}`) - } - - // Verify depends_on contains all strings now - const { depends_on } = result.data - if (!Array.isArray(depends_on)) { - throw new Error("Expected depends_on to be an array") - } - - if (depends_on.length !== 3) { - throw new Error(`Expected 3 dependencies, got ${depends_on.length}`) - } +test("runComposer rejects numeric depends_on values", async () => { + const mockSpawn = async () => + JSON.stringify({ + status: "ready", + tasks: [ + { + title: "Task A", + description: "desc", + acceptance_criteria: "criteria", + task_type: "implementation" as const, + labels: ["module:test"], + depends_on: [], + priority: 0, + }, + { + title: "Task B", + description: "desc", + acceptance_criteria: "criteria", + task_type: "implementation" as const, + labels: ["module:test"], + depends_on: [1], // numeric — should be rejected + priority: 1, + }, + ], + }) - const hasNonStringDeps = depends_on.some((d) => typeof d !== "string") - if (hasNonStringDeps) { - throw new Error(`Expected all depends_on values to be strings, got: ${JSON.stringify(depends_on)}`) + let threw = false + try { + await runComposer( + { + jobId: "job-1", + projectId: "test-project", + pmSessionId: "session-1", + issueNumber: 123, + issueTitle: "Add feature", + issueBody: "Please add a feature.", + }, + mockSpawn, + ) + } catch (e) { + threw = true + const msg = (e as Error).message + if (!msg.includes("validation failed")) { + throw new Error(`Expected validation error for numeric depends_on, got: ${msg}`) + } } - // Verify the values are coerced correctly - if (depends_on[0] !== "1" || depends_on[1] !== "2" || depends_on[2] !== "existing-task") { - throw new Error(`Expected ["1", "2", "existing-task"], got ${JSON.stringify(depends_on)}`) + if (!threw) { + throw new Error("Expected runComposer to throw validation error for numeric depends_on") } }) \ No newline at end of file