Inherit chat_jid into agent-created controlplane tasks (Sam & Claude)

Bug: when the chat agent decides a request needs a controlplane task
(e.g. "any system updates available?" — needs root-level audit), it
calls task_create. The task is stored with context: null because the
tool's parameter schema didn't accept context and nothing in the path
auto-injected the originating chat. controlplane-heartbeat.ts:874 then
reads context.chat_jid to deliver the result back to Telegram, finds
nothing, and the task completes with no thread back to the operator.

Sam saw this on 10.maj.2026 — the queue fix from b6d72d9 stopped silent
message loss, which immediately exposed silent task-result loss.

Fix:

- src/agent-runner.ts now passes CLAWDIE_CHAT_JID=input.chatJid to the
  spawned pi process. The chat jid was already available at the call
  site, just not exported.
- .pi/extensions/clawdie-harness/controlplane-tools.ts gets a new pure
  helper buildTaskCreateBody(params, env) that auto-injects
  context.chat_jid from $CLAWDIE_CHAT_JID. The task_create tool calls
  the helper. Agent doesn't need to know about chat context — the
  harness inherits it from its parent process.

The fix is asymmetric in a deliberate way: agent-runner sets the env
var unconditionally (every chat run has a chat_jid), but the helper
only injects context when the env var is non-empty. Scheduled-task
runs that don't yet thread chat context will simply omit context, same
as today — no regression there.

Tests:

- 5 new tests in controlplane-tools.test.ts cover the helper directly:
  no env → no context, blank env → no context, set env → context with
  chat_jid, plus priority + assigned_to passthrough.
- vitest.config.ts now includes .pi/extensions/**/*.test.ts so the
  harness tests run with `npm test` and `just test` instead of needing
  a separate runner invocation. This picks up the existing
  controlplane-tools and system-tools test files (the harness rename
  commit added them but they weren't in the default include).

Out of scope deliberately: src/controlplane-runner.ts (controlplane
heartbeat path that runs subtasks) does not yet read task.context.
chat_jid to thread it forward to its own spawned agents. So if a
controlplane task itself spawns subtasks, those subtasks won't inherit
chat_jid via this commit. The original bug (chat → agent → task) is
fixed end-to-end; sub-task propagation is a follow-up if needed.
Per docs/internal/SUDO_REPLACEMENT.md, the next privileged-op gap
(sysadmin can't run pkg/freebsd-update audits) belongs in hostd, not
in a sudo replacement.

---
2251 passed locally. Pre-existing argon2/controlplane-*/cms.test
failures unchanged. Codex to validate end-to-end on host: send a
chat-driven question that triggers a controlplane task, confirm
the task lands with context.chat_jid populated and the completion
message reaches the originating Telegram thread.

---
Build: FAIL | Tests: FAIL — 16 failed
This commit is contained in:
Operator & Claude Code 2026-05-10 08:57:27 +02:00
parent 0724a28d68
commit c9f9bdb63d
4 changed files with 92 additions and 8 deletions

View file

@ -9,7 +9,58 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import { taskDecomposeTool, taskStatusTool } from './controlplane-tools.js';
import {
buildTaskCreateBody,
taskDecomposeTool,
taskStatusTool,
} from './controlplane-tools.js';
describe('buildTaskCreateBody', () => {
const baseParams = {
title: 'check pending updates',
description: 'audit host + jails for available patches',
};
it('omits context when CLAWDIE_CHAT_JID is unset', () => {
const body = buildTaskCreateBody(baseParams, {});
expect(body).toEqual({
title: 'check pending updates',
description: 'audit host + jails for available patches',
priority: 'medium',
});
expect('context' in body).toBe(false);
});
it('omits context when CLAWDIE_CHAT_JID is empty or whitespace', () => {
const blank = buildTaskCreateBody(baseParams, { CLAWDIE_CHAT_JID: ' ' });
expect('context' in blank).toBe(false);
});
it('injects context.chat_jid when CLAWDIE_CHAT_JID is set', () => {
const body = buildTaskCreateBody(baseParams, {
CLAWDIE_CHAT_JID: '120363401234567890@g.us',
});
expect(body.context).toEqual({ chat_jid: '120363401234567890@g.us' });
});
it('preserves explicit priority and assigned_to', () => {
const body = buildTaskCreateBody(
{ ...baseParams, priority: 'high', assigned_to: 'sysadmin' },
{ CLAWDIE_CHAT_JID: 'group-1' },
);
expect(body).toMatchObject({
title: baseParams.title,
priority: 'high',
assigned_to: 'sysadmin',
context: { chat_jid: 'group-1' },
});
});
it('defaults priority to medium when omitted', () => {
const body = buildTaskCreateBody(baseParams, {});
expect(body.priority).toBe('medium');
});
});
import http from 'http';
// ---------------------------------------------------------------------------

View file

@ -49,6 +49,35 @@ function getApiConfig(): {
};
}
/**
* Build the request body for POST /api/controlplane/tasks. Auto-injects the
* originating chat jid from $CLAWDIE_CHAT_JID into the task's context so the
* controlplane heartbeat can deliver the result back to the right Telegram
* thread on completion. Without this, agent-spawned tasks orphan their
* results the bug from 10.maj.2026.
*/
export function buildTaskCreateBody(
params: {
title: string;
description: string;
assigned_to?: string;
priority?: string;
},
env: NodeJS.ProcessEnv = process.env,
): Record<string, unknown> {
const body: Record<string, unknown> = {
title: params.title,
description: params.description,
priority: params.priority ?? 'medium',
};
if (params.assigned_to) body.assigned_to = params.assigned_to;
const chatJid = (env.CLAWDIE_CHAT_JID ?? '').trim();
if (chatJid) {
body.context = { chat_jid: chatJid };
}
return body;
}
function apiRequest<T>(
method: string,
path: string,
@ -218,12 +247,7 @@ export const taskCreateTool = {
_ctx: ExtensionContext,
): Promise<AgentToolResult<unknown>> {
try {
const body: Record<string, unknown> = {
title: params.title,
description: params.description,
priority: params.priority ?? 'medium',
};
if (params.assigned_to) body.assigned_to = params.assigned_to;
const body = buildTaskCreateBody(params);
const res = await apiRequest<{ task?: { id: string }; error?: string }>(
'POST',
'/api/controlplane/tasks',

View file

@ -597,6 +597,10 @@ export async function runJailAgent(
NODE_ENV: process.env.NODE_ENV ?? 'production',
...readSecrets(),
TENANT_ID,
// Surface the originating chat jid so the harness can auto-inject it as
// task context when the agent creates controlplane tasks. Without this,
// task results have no thread back to the chat that asked for them.
CLAWDIE_CHAT_JID: input.chatJid,
};
const controlplaneApiKey =

View file

@ -2,6 +2,11 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts', 'setup/**/*.test.ts', 'skills-engine/**/*.test.ts'],
include: [
'src/**/*.test.ts',
'setup/**/*.test.ts',
'skills-engine/**/*.test.ts',
'.pi/extensions/**/*.test.ts',
],
},
});