diff --git a/CHANGELOG.md b/CHANGELOG.md index e66bc7f..6e16d49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to `@useorgx/openclaw-plugin` are documented in this file. +## 0.7.31 - 2026-04-13 + +### Release Management +- Patch release bump for npm package, lockfile, and plugin manifest metadata. + +### Outcome Reporting + Proof Chain +- Fixed `orgx_record_outcome` so coordinator calls include the run context required by OrgX reporting APIs, while preserving explicit `run_id` overrides. +- Added regression coverage for strict MCP schema behavior and outcome payload forwarding. + +### Autopilot Continuation Reliability +- Kept closed slice child processes available until reconciliation so the auto-continue engine can read their terminal output before clearing process state. +- Prevented closed slices with stale PIDs from being treated as still alive and falsely killed by stall detection. + ## 0.7.30 - 2026-03-28 ### Release Management diff --git a/openclaw.plugin.json b/openclaw.plugin.json index e62deb5..a0c22ae 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -1,7 +1,7 @@ { "id": "orgx", "name": "OrgX for OpenClaw", - "version": "0.7.30", + "version": "0.7.31", "description": "Persistent organizational memory and coordinated execution for OpenClaw agents", "entry": "./dist/index.js", "author": "OrgX Team", diff --git a/package-lock.json b/package-lock.json index 2da779d..e473171 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@useorgx/openclaw-plugin", - "version": "0.7.30", + "version": "0.7.31", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@useorgx/openclaw-plugin", - "version": "0.7.30", + "version": "0.7.31", "license": "MIT", "dependencies": { "better-sqlite3": "^11.10.0" diff --git a/package.json b/package.json index e16477f..16d80d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@useorgx/openclaw-plugin", - "version": "0.7.30", + "version": "0.7.31", "description": "Persistent organizational memory and coordination for OpenClaw agents", "type": "module", "main": "./dist/index.js", diff --git a/src/http/helpers/auto-continue-engine.ts b/src/http/helpers/auto-continue-engine.ts index 3212acc..e81e5e4 100644 --- a/src/http/helpers/auto-continue-engine.ts +++ b/src/http/helpers/auto-continue-engine.ts @@ -2760,8 +2760,14 @@ export function createAutoContinueEngine(deps: CreateAutoContinueEngineDeps) { run.activeTaskId = null; run.updatedAt = now; } else { - const pid = slice.pid; - if (pid && pidAlive(pid)) { + const pid = slice.pid; + const child = autoContinueSliceChildren.get(slice.runId) ?? null; + const childClosed = Boolean(child && (child.exitCode !== null || child.signalCode !== null)); + if (childClosed && slice.pid !== null) { + slice.pid = null; + autoContinueSliceRuns.set(slice.runId, slice); + } + if (pid && !childClosed && pidAlive(pid)) { const nowMs = Date.now(); const outputTail = readFileTailSafe(slice.outputPath, 240_000); const outputParsed = outputTail diff --git a/src/http/helpers/autopilot-runtime.ts b/src/http/helpers/autopilot-runtime.ts index c1b50af..15851a5 100644 --- a/src/http/helpers/autopilot-runtime.ts +++ b/src/http/helpers/autopilot-runtime.ts @@ -319,7 +319,6 @@ export function createAutopilotRuntime(deps: CreateAutopilotRuntimeDeps) { }); child.on("close", (code, signal) => { - deps.autoContinueSliceChildren.delete(input.runId); const stamp = new Date().toISOString(); const wroteFallback = writeFallbackSliceOutput({ outputPath: input.outputPath, @@ -350,7 +349,6 @@ export function createAutopilotRuntime(deps: CreateAutopilotRuntimeDeps) { } }); child.on("error", (error) => { - deps.autoContinueSliceChildren.delete(input.runId); const msg = deps.safeErrorMessage(error); try { logStream.write(`\nworker error: ${msg}\n`); @@ -496,7 +494,6 @@ export function createAutopilotRuntime(deps: CreateAutopilotRuntimeDeps) { }); child.on("close", (code, signal) => { - deps.autoContinueSliceChildren.delete(input.runId); const stamp = new Date().toISOString(); const wroteFallback = writeFallbackSliceOutput({ outputPath: input.outputPath, @@ -528,7 +525,6 @@ export function createAutopilotRuntime(deps: CreateAutopilotRuntimeDeps) { }); child.on("error", (error) => { - deps.autoContinueSliceChildren.delete(input.runId); const msg = deps.safeErrorMessage(error); try { logStream.write(`\nworker error: ${msg}\n`); @@ -663,7 +659,6 @@ export function createAutopilotRuntime(deps: CreateAutopilotRuntimeDeps) { }); child.on("close", (code, signal) => { - deps.autoContinueSliceChildren.delete(input.runId); const stamp = new Date().toISOString(); const wroteFallback = writeFallbackSliceOutput({ outputPath: input.outputPath, @@ -689,7 +684,6 @@ export function createAutopilotRuntime(deps: CreateAutopilotRuntimeDeps) { } }); child.on("error", (error) => { - deps.autoContinueSliceChildren.delete(input.runId); const msg = deps.safeErrorMessage(error); try { logStream.write(`\nworker error: ${msg}\n`); diff --git a/src/tools/core-tools.ts b/src/tools/core-tools.ts index 52efefa..377a226 100644 --- a/src/tools/core-tools.ts +++ b/src/tools/core-tools.ts @@ -973,6 +973,27 @@ export function registerCoreTools(deps: RegisterCoreToolsDeps): Map; + queue_ref?: { + initiative_id?: string; + workstream_id?: string; + task_id?: string; + }; + run_ref?: { + run_id?: string; + correlation_id?: string; + session_id?: string; + }; } = { name: "", artifact_type: "other" } ) { const now = new Date().toISOString(); @@ -3227,6 +3304,26 @@ export function registerCoreTools(deps: RegisterCoreToolsDeps): Map, activityItem, }); diff --git a/tests/tools/core-tools-record-outcome.test.mjs b/tests/tools/core-tools-record-outcome.test.mjs new file mode 100644 index 0000000..3b04147 --- /dev/null +++ b/tests/tools/core-tools-record-outcome.test.mjs @@ -0,0 +1,125 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { registerCoreTools } from "../../dist/tools/core-tools.js"; + +function createDeps(overrides = {}) { + const recordedPayloads = []; + + const deps = { + registerTool: () => {}, + client: { + syncMemory: async () => ({}), + getMorningBrief: async () => ({ session: { id: "session-1" } }), + queryOrgMemory: async () => ({ results: [] }), + recommendNextAction: async () => ({ recommendations: [] }), + checkSpawnGuard: async () => ({ ok: true, allowed: true, modelTier: "sonnet", checks: {} }), + createEntity: async () => ({}), + updateEntity: async () => ({}), + updateEntityDetailed: async () => ({ entity: {} }), + listEntities: async () => ({ data: [] }), + emitActivity: async () => ({}), + applyChangeset: async () => ({ applied_count: 1, replayed: false, run_id: "run" }), + recordRunOutcome: async (payload) => { + recordedPayloads.push(payload); + return { + ok: true, + run_id: payload.run_id ?? "run-from-correlation", + reused_run: false, + execution_id: payload.execution_id, + event_id: "event-1", + }; + }, + }, + config: { syncIntervalMs: 10_000, pluginVersion: "test" }, + getCachedSnapshot: () => null, + getLastSnapshotAt: () => 0, + doSync: async () => {}, + text: (value) => ({ content: [{ type: "text", text: value }] }), + json: (label, data) => ({ content: [{ type: "text", text: `${label}\n${JSON.stringify(data)}` }] }), + formatSnapshot: () => "snapshot", + autoAssignEntityForCreate: async () => ({ assignmentSource: "manual", assignedAgents: [], warnings: [] }), + toReportingPhase: () => "execution", + inferReportingInitiativeId: () => undefined, + isUuid: () => true, + pickNonEmptyString: (...values) => { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return undefined; + }, + resolveReportingContext: () => ({ ok: false, error: "unused" }), + readSkillPackState: () => ({}), + randomUUID: () => "uuid-test", + ...overrides, + }; + + return { deps, recordedPayloads }; +} + +test("orgx_record_outcome has a strict schema", () => { + const { deps } = createDeps(); + const tool = registerCoreTools(deps).get("orgx_record_outcome"); + + assert.ok(tool); + assert.equal(tool.parameters?.type, "object"); + assert.equal(tool.parameters?.additionalProperties, false); + assert.equal(tool.parameters?.properties?.source_client?.enum?.includes("codex"), true); +}); + +test("orgx_record_outcome derives reporting context for coordinator calls", async () => { + const { deps, recordedPayloads } = createDeps(); + const tool = registerCoreTools(deps).get("orgx_record_outcome"); + + const result = await tool.execute("call-outcome", { + initiative_id: "initiative-1", + execution_id: "execution-1", + agent_id: "orchestrator-agent", + success: true, + quality_score: 5, + domain: "orchestrator", + metadata: { outcome_type: "initiative_completed" }, + }); + + assert.match(result.content[0].text, /Outcome recorded/); + assert.deepEqual(recordedPayloads[0], { + initiative_id: "initiative-1", + execution_id: "execution-1", + execution_type: "agent_run", + agent_id: "orchestrator-agent", + success: true, + quality_score: 5, + domain: "orchestrator", + metadata: { outcome_type: "initiative_completed" }, + correlation_id: "execution-1", + source_client: "openclaw", + }); +}); + +test("orgx_record_outcome forwards explicit run context overrides", async () => { + const { deps, recordedPayloads } = createDeps(); + const tool = registerCoreTools(deps).get("orgx_record_outcome"); + + await tool.execute("call-outcome", { + initiative_id: "initiative-1", + execution_id: "execution-1", + execution_type: "codex.coordinator", + run_id: "run-1", + correlation_id: "correlation-1", + source_client: "codex", + agent_id: "orchestrator-agent", + success: true, + }); + + assert.deepEqual(recordedPayloads[0], { + initiative_id: "initiative-1", + execution_id: "execution-1", + execution_type: "codex.coordinator", + agent_id: "orchestrator-agent", + success: true, + quality_score: undefined, + domain: undefined, + metadata: undefined, + run_id: "run-1", + }); +}); diff --git a/tests/tools/core-tools-register-artifact.test.mjs b/tests/tools/core-tools-register-artifact.test.mjs new file mode 100644 index 0000000..cd5f770 --- /dev/null +++ b/tests/tools/core-tools-register-artifact.test.mjs @@ -0,0 +1,157 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { registerCoreTools } from "../../dist/tools/core-tools.js"; + +const INITIATIVE_ID = "11111111-1111-4111-8111-111111111111"; +const WORKSTREAM_ID = "22222222-2222-4222-8222-222222222222"; +const TASK_ID = "33333333-3333-4333-8333-333333333333"; +const ARTIFACT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + +function createDeps(overrides = {}) { + const requests = []; + + const deps = { + registerTool: () => {}, + client: { + getBaseUrl: () => "https://www.useorgx.com", + rawRequest: async (method, path, body) => { + requests.push({ method, path, body }); + + if (method === "POST" && path === "/api/client/artifacts") { + return { + artifact: { + id: body.artifact_id, + artifact_url: body.artifact_url, + entity_type: body.entity_type, + entity_id: body.entity_id, + }, + }; + } + + if (method === "GET" && path === `/api/client/artifacts/${ARTIFACT_ID}`) { + return { artifact: { id: ARTIFACT_ID } }; + } + + if (method === "GET" && path.startsWith("/api/client/artifacts/by-entity?")) { + return { + artifacts: [ + { + id: ARTIFACT_ID, + entity_type: "task", + entity_id: TASK_ID, + }, + ], + }; + } + + throw new Error(`Unexpected request: ${method} ${path}`); + }, + createEntity: async () => { + throw new Error("legacy artifact create should not be used"); + }, + updateEntity: async () => ({}), + updateEntityDetailed: async () => ({ entity: {} }), + listEntities: async () => ({ data: [] }), + emitActivity: async () => ({}), + applyChangeset: async () => ({ applied_count: 1, replayed: false, run_id: "run" }), + }, + config: { syncIntervalMs: 10_000, pluginVersion: "test" }, + getCachedSnapshot: () => null, + getLastSnapshotAt: () => 0, + doSync: async () => {}, + text: (value) => ({ content: [{ type: "text", text: value }] }), + json: (label, data) => ({ content: [{ type: "text", text: `${label}\n${JSON.stringify(data)}` }] }), + formatSnapshot: () => "snapshot", + autoAssignEntityForCreate: async () => ({ assignmentSource: "manual", assignedAgents: [], warnings: [] }), + toReportingPhase: () => "execution", + inferReportingInitiativeId: () => undefined, + isUuid: (value) => + typeof value === "string" && + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value), + pickNonEmptyString: (...values) => { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return undefined; + }, + resolveReportingContext: () => ({ ok: false, error: "unused" }), + readSkillPackState: () => ({}), + randomUUID: () => ARTIFACT_ID, + ...overrides, + }; + + return { deps, requests }; +} + +test("orgx_register_artifact exposes proof metadata fields in a strict schema", () => { + const { deps } = createDeps(); + const tool = registerCoreTools(deps).get("orgx_register_artifact"); + + assert.ok(tool, "expected orgx_register_artifact to be registered"); + assert.equal(tool.parameters.type, "object"); + assert.equal(tool.parameters.additionalProperties, false); + assert.ok(tool.parameters.properties.metadata, "metadata should be accepted"); + assert.ok(tool.parameters.properties.queue_ref, "queue_ref should be accepted"); + assert.ok(tool.parameters.properties.run_ref, "run_ref should be accepted"); + assert.equal(tool.parameters.properties.queue_ref.additionalProperties, false); + assert.equal(tool.parameters.properties.run_ref.additionalProperties, false); +}); + +test("orgx_register_artifact forwards caller proof metadata to durable artifacts", async () => { + const { deps, requests } = createDeps(); + const tool = registerCoreTools(deps).get("orgx_register_artifact"); + + const result = await tool.execute("call-register-artifact", { + entity_type: "task", + entity_id: TASK_ID, + name: "Public live data exposure hardening", + artifact_type: "engineering.commit", + confidence_score: 1, + description: "Commit and verification proof for public /live sanitization hardening.", + url: "https://github.com/hopeatina/orgx/commit/a329648b", + content: "Verified with focused Vitest suites, typecheck, and git diff --check.", + metadata: { + commit_sha: "a329648b", + branch: "codex/content-queue-live", + repo: "hopeatina/orgx", + quality_gate: "passed", + }, + queue_ref: { + initiative_id: INITIATIVE_ID, + workstream_id: WORKSTREAM_ID, + task_id: TASK_ID, + }, + run_ref: { + correlation_id: "audit-public-live-data-exposure-a329648b", + }, + }); + + assert.match(result.content[0].text, /Artifact registered: Public live data exposure hardening/); + + const createRequest = requests.find( + (request) => request.method === "POST" && request.path === "/api/client/artifacts" + ); + assert.ok(createRequest, "expected canonical artifact create request"); + + assert.equal(createRequest.body.entity_type, "task"); + assert.equal(createRequest.body.entity_id, TASK_ID); + assert.equal(createRequest.body.artifact_id, ARTIFACT_ID); + assert.equal(createRequest.body.artifact_type, "engineering.commit"); + assert.equal(createRequest.body.metadata.commit_sha, "a329648b"); + assert.equal(createRequest.body.metadata.branch, "codex/content-queue-live"); + assert.equal(createRequest.body.metadata.repo, "hopeatina/orgx"); + assert.equal(createRequest.body.metadata.quality_gate, "passed"); + assert.equal(createRequest.body.metadata.source, "orgx_register_artifact"); + assert.equal(createRequest.body.metadata.artifact_id, ARTIFACT_ID); + assert.equal(createRequest.body.metadata.confidence_score, 1); + assert.deepEqual(createRequest.body.metadata.queue_ref, { + initiative_id: INITIATIVE_ID, + workstream_id: WORKSTREAM_ID, + task_id: TASK_ID, + }); + assert.deepEqual(createRequest.body.metadata.run_ref, { + correlation_id: "audit-public-live-data-exposure-a329648b", + }); + assert.ok(createRequest.body.metadata.artifact_hash, "expected artifact hash to be generated"); +});