From b4b030cce36b8a2257753799b03ae61acefb8831 Mon Sep 17 00:00:00 2001 From: Omer Date: Wed, 27 May 2026 10:56:46 +0000 Subject: [PATCH] feat: parent workflow runner sessions --- docs/guides/TOOLS.md | 7 ++- src/index.js | 102 +++++++++++++++++++++++++++++++++++++++-- tests/validate.test.js | 59 ++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 4 deletions(-) diff --git a/docs/guides/TOOLS.md b/docs/guides/TOOLS.md index 092bfb6..1b21ef3 100644 --- a/docs/guides/TOOLS.md +++ b/docs/guides/TOOLS.md @@ -95,11 +95,15 @@ Starts a `workflow_runner` session for a complex task. - Only available in `full` team mode. - Used for `complex` implementation tasks. -- The runner executes in a separate session and reports completion back to PMA. +- The runner executes in a separate OpenCode session and reports completion back to PMA. +- PMA-created runner sessions are created with `parentID` set to the current PMA session ID, so OpenCode can show the runner under the PMA session in the session tree. +- `parentID` is organizational metadata only. It does **not** inherit parent session context, transcript history, tools, permissions, or active prompts; all required task context must still be included in the workflow instructions and task file. +- Runtime completion relay and concurrency control continue to use NomadWorks' in-memory `activeWorkflows` registry, which tracks PMA session ID, task path, and workflow track for active runner sessions. - The runner is expected to orchestrate the lifecycle by validating task readiness, delegating implementation and verification work to specialists, and driving the task to delivery or a hard blocker. - For implementation tasks, the runner must create or append a Workflow Execution Plan in the task file after Pre-Task Sync and before implementation starts. - The runner must not directly edit product source code, tests, application configuration, or implementation files. - When a hard blocker is reached, the runner should end its run and return a final summary starting with `HARD BLOCKER:` so the plugin relays it back to the PMA session. +- If the runner delegates to specialist sub-sessions, those sub-sessions are not automatically parented to PMA by `nomadflow_run_workflow` and do not receive context through the PMA runner `parentID`. ## `nomadflow_prompt_workflow` @@ -114,3 +118,4 @@ Sends a follow-up prompt to an existing `workflow_runner` session. - Only available in `full` team mode. - Useful for bounce-backs, clarifications, and resumed runner work. +- If the session is not already tracked in `activeWorkflows`, this tool resumes monitoring for completion relay to the current PMA session. diff --git a/src/index.js b/src/index.js index 6b51122..2827d2e 100644 --- a/src/index.js +++ b/src/index.js @@ -19,6 +19,99 @@ const LEGACY_NOMADWORKS_DIRNAME = ".codenomad"; const activeWorkflows = new Map(); // sessionId -> { pmaSessionId, taskPath, track } +function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} + +export function extractWorkflowSessionId(sessionResult) { + const candidates = [ + sessionResult?.data?.id, + sessionResult?.data?.sessionID, + sessionResult?.data?.sessionId, + sessionResult?.data?.session?.id, + sessionResult?.data?.session?.ID, + sessionResult?.data?.info?.sessionID, + sessionResult?.data?.info?.id, + sessionResult?.id, + sessionResult?.sessionID, + sessionResult?.sessionId, + sessionResult?.session?.id + ]; + + const sessionId = candidates.find(isNonEmptyString); + if (sessionId) return sessionId.trim(); + + throw new Error("OpenCode session.create response missing session ID. Expected data.id, data.session.id, data.sessionID, or equivalent top-level ID."); +} + +function textFromParts(parts) { + if (!Array.isArray(parts)) return ""; + + return parts + .map(part => { + if (isNonEmptyString(part?.text)) return part.text; + if (isNonEmptyString(part?.content)) return part.content; + if (isNonEmptyString(part?.input)) return part.input; + return ""; + }) + .filter(Boolean) + .join("\n") + .trim(); +} + +function textFromMessageList(messages) { + if (!Array.isArray(messages)) return ""; + + const assistantMessages = messages.filter(message => message?.role === "assistant" || message?.info?.role === "assistant"); + const message = assistantMessages.at(-1) || messages.at(-1); + return textFromParts(message?.parts) || (isNonEmptyString(message?.text) ? message.text.trim() : ""); +} + +export function extractWorkflowFinalMessage(runResult) { + const partSources = [ + runResult?.data?.parts, + runResult?.parts, + runResult?.data?.message?.parts, + runResult?.message?.parts, + runResult?.data?.response?.parts, + runResult?.response?.parts, + runResult?.data?.result?.parts, + runResult?.result?.parts, + runResult?.data?.output?.parts, + runResult?.output?.parts + ]; + + for (const parts of partSources) { + const text = textFromParts(parts); + if (text) return text; + } + + const messageSources = [ + runResult?.data?.messages, + runResult?.messages, + runResult?.data?.result?.messages, + runResult?.result?.messages + ]; + + for (const messages of messageSources) { + const text = textFromMessageList(messages); + if (text) return text; + } + + const textSources = [ + runResult?.data?.text, + runResult?.text, + runResult?.data?.content, + runResult?.content, + runResult?.data?.output, + runResult?.output + ]; + const text = textSources.find(isNonEmptyString); + if (text) return text.trim(); + + throw new Error("OpenCode session.prompt response missing final message text. Expected text in parts, messages, text/content, or output fields."); +} + function nomadworksDir(worktree) { return path.join(worktree, NOMADWORKS_DIRNAME); } @@ -821,7 +914,7 @@ export default async function NomadWorksPlugin(input) { if (debug) console.log(`[NomadFlow] Workflow Runner session ${sessionId} returned control.`); // Capture final message and notify PMA - const finalMessage = runResult.data.parts.map(p => p.text).join("\n"); + const finalMessage = extractWorkflowFinalMessage(runResult); if (debug) console.log(`[NomadFlow] Attempting to notify PMA session ${pmaSessionId} of completion...`); await client.session.promptAsync({ @@ -1157,9 +1250,12 @@ export default async function NomadWorksPlugin(input) { try { // 1. Create a new session const sessionResult = await client.session.create({ - body: { title: `Workflow Run: ${path.basename(args.task_path)}` } + body: { + title: `Workflow Run: ${path.basename(args.task_path)}`, + parentID: pmaSessionId + } }); - const sessionId = sessionResult.data.id; + const sessionId = extractWorkflowSessionId(sessionResult); activeWorkflows.set(sessionId, { pmaSessionId, taskPath: args.task_path, track: workflowTrack }); diff --git a/tests/validate.test.js b/tests/validate.test.js index a9d6055..6d4f3f1 100644 --- a/tests/validate.test.js +++ b/tests/validate.test.js @@ -1,4 +1,6 @@ import { nomadworks_validate_logic } from "../src/validate_logic.js"; +import NomadWorksPlugin, { extractWorkflowFinalMessage, extractWorkflowSessionId } from "../src/index.js"; +import { jest } from "@jest/globals"; import fs from "node:fs"; import path from "node:path"; import os from "node:os"; @@ -23,6 +25,21 @@ function createTestEnv(structure) { return root; } +function createFullTeamPluginEnv() { + return createTestEnv({ + ".nomadworks": { + "nomadworks.yaml": "enabled: true\nteam_mode: full\nfeatures:\n debug_dumps: false\nagents:\n" + }, + "tasks": { + "workflow.md": "---\ntrack: implementation\n---\n# Workflow Task" + } + }); +} + +function nextTick() { + return new Promise(resolve => setImmediate(resolve)); +} + describe("nomadworks_validate", () => { test("Passes on valid hierarchical structure", async () => { const root = createTestEnv({ @@ -161,3 +178,45 @@ describe("nomadworks_validate", () => { expect(result.ok).toBe(true); // Should ignore .png and .txt }); }); + +describe("workflow runner session parenting and response extraction", () => { + test("nomadflow_run_workflow creates child session with parentID", async () => { + const root = createFullTeamPluginEnv(); + const create = jest.fn(async () => ({ data: { id: "runner-session" } })); + const prompt = jest.fn(async () => ({ data: { parts: [{ type: "text", text: "done" }] } })); + const promptAsync = jest.fn(async () => ({})); + + const plugin = await NomadWorksPlugin({ + worktree: root, + client: { session: { create, prompt, promptAsync } } + }); + + const result = await plugin.tool.nomadflow_run_workflow.execute( + { task_path: "tasks/workflow.md", instructions: "Run task" }, + { worktree: root, sessionId: "pma-session" } + ); + await nextTick(); + + expect(result).toContain("SUCCESS: Workflow Runner session started. ID: runner-session"); + expect(create).toHaveBeenCalledWith({ + body: { + title: "Workflow Run: workflow.md", + parentID: "pma-session" + } + }); + }); + + test("extractWorkflowSessionId supports guarded session response shapes", () => { + expect(extractWorkflowSessionId({ data: { id: "data-id" } })).toBe("data-id"); + expect(extractWorkflowSessionId({ data: { session: { id: "nested-id" } } })).toBe("nested-id"); + expect(extractWorkflowSessionId({ sessionID: "top-session-id" })).toBe("top-session-id"); + expect(() => extractWorkflowSessionId({ data: {} })).toThrow(/missing session ID/); + }); + + test("extractWorkflowFinalMessage supports missing data.parts alternatives", () => { + expect(extractWorkflowFinalMessage({ data: { message: { parts: [{ text: "nested parts" }] } } })).toBe("nested parts"); + expect(extractWorkflowFinalMessage({ data: { messages: [{ role: "assistant", parts: [{ text: "assistant message" }] }] } })).toBe("assistant message"); + expect(extractWorkflowFinalMessage({ data: { text: "plain text" } })).toBe("plain text"); + expect(() => extractWorkflowFinalMessage({ data: {} })).toThrow(/missing final message text/); + }); +});