fix: block stale paired IPC and duplicate finalize turns
This commit is contained in:
@@ -31,8 +31,9 @@ import {
|
|||||||
import { resolveIpcDirectories } from './ipc-paths.js';
|
import { resolveIpcDirectories } from './ipc-paths.js';
|
||||||
import { buildSendMessageIpcPayload } from './ipc-message.js';
|
import { buildSendMessageIpcPayload } from './ipc-message.js';
|
||||||
|
|
||||||
const { ipcDir: IPC_DIR, hostIpcDir: HOST_IPC_DIR } =
|
const { ipcDir: IPC_DIR, hostIpcDir: HOST_IPC_DIR } = resolveIpcDirectories(
|
||||||
resolveIpcDirectories(process.env);
|
process.env,
|
||||||
|
);
|
||||||
const MESSAGES_DIR = path.join(HOST_IPC_DIR, 'messages');
|
const MESSAGES_DIR = path.join(HOST_IPC_DIR, 'messages');
|
||||||
const TASKS_DIR = path.join(HOST_IPC_DIR, 'tasks');
|
const TASKS_DIR = path.join(HOST_IPC_DIR, 'tasks');
|
||||||
const HOST_EVIDENCE_RESPONSES_DIR =
|
const HOST_EVIDENCE_RESPONSES_DIR =
|
||||||
@@ -71,7 +72,12 @@ server.tool(
|
|||||||
"Send a message to the user or group immediately while you're still running. Use this for progress updates or to send multiple messages. You can call this multiple times.",
|
"Send a message to the user or group immediately while you're still running. Use this for progress updates or to send multiple messages. You can call this multiple times.",
|
||||||
{
|
{
|
||||||
text: z.string().describe('The message text to send'),
|
text: z.string().describe('The message text to send'),
|
||||||
sender: z.string().optional().describe('Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.'),
|
sender: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.',
|
||||||
|
),
|
||||||
},
|
},
|
||||||
async (args) => {
|
async (args) => {
|
||||||
const data = buildSendMessageIpcPayload({
|
const data = buildSendMessageIpcPayload({
|
||||||
@@ -79,6 +85,7 @@ server.tool(
|
|||||||
text: args.text,
|
text: args.text,
|
||||||
sender: args.sender || undefined,
|
sender: args.sender || undefined,
|
||||||
senderRole: process.env.EJCLAW_ROOM_ROLE || undefined,
|
senderRole: process.env.EJCLAW_ROOM_ROLE || undefined,
|
||||||
|
runId: process.env.EJCLAW_RUN_ID || undefined,
|
||||||
groupFolder,
|
groupFolder,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,11 +120,33 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
|
|||||||
\u2022 interval: Milliseconds between runs (e.g., "300000" for 5 minutes, "3600000" for 1 hour)
|
\u2022 interval: Milliseconds between runs (e.g., "300000" for 5 minutes, "3600000" for 1 hour)
|
||||||
\u2022 once: Local time WITHOUT "Z" suffix (e.g., "2026-02-01T15:30:00"). Do NOT use UTC/Z suffix.`,
|
\u2022 once: Local time WITHOUT "Z" suffix (e.g., "2026-02-01T15:30:00"). Do NOT use UTC/Z suffix.`,
|
||||||
{
|
{
|
||||||
prompt: z.string().describe('What the agent should do when the task runs. For isolated mode, include all necessary context here.'),
|
prompt: z
|
||||||
schedule_type: z.enum(['cron', 'interval', 'once']).describe('cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time'),
|
.string()
|
||||||
schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)'),
|
.describe(
|
||||||
context_mode: z.enum(['group', 'isolated']).default('group').describe('group=runs with chat history and memory, isolated=fresh session (include context in prompt)'),
|
'What the agent should do when the task runs. For isolated mode, include all necessary context here.',
|
||||||
target_group_jid: z.string().optional().describe('(Main group only) JID of the group to schedule the task for. Defaults to the current group.'),
|
),
|
||||||
|
schedule_type: z
|
||||||
|
.enum(['cron', 'interval', 'once'])
|
||||||
|
.describe(
|
||||||
|
'cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time',
|
||||||
|
),
|
||||||
|
schedule_value: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
'cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)',
|
||||||
|
),
|
||||||
|
context_mode: z
|
||||||
|
.enum(['group', 'isolated'])
|
||||||
|
.default('group')
|
||||||
|
.describe(
|
||||||
|
'group=runs with chat history and memory, isolated=fresh session (include context in prompt)',
|
||||||
|
),
|
||||||
|
target_group_jid: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'(Main group only) JID of the group to schedule the task for. Defaults to the current group.',
|
||||||
|
),
|
||||||
},
|
},
|
||||||
async (args) => {
|
async (args) => {
|
||||||
// Validate schedule_value before writing IPC
|
// Validate schedule_value before writing IPC
|
||||||
@@ -126,7 +155,12 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
|
|||||||
CronExpressionParser.parse(args.schedule_value);
|
CronExpressionParser.parse(args.schedule_value);
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: `Invalid cron: "${args.schedule_value}". Use format like "0 9 * * *" (daily 9am) or "*/5 * * * *" (every 5 min).` }],
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Invalid cron: "${args.schedule_value}". Use format like "0 9 * * *" (daily 9am) or "*/5 * * * *" (every 5 min).`,
|
||||||
|
},
|
||||||
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -134,28 +168,47 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
|
|||||||
const ms = parseInt(args.schedule_value, 10);
|
const ms = parseInt(args.schedule_value, 10);
|
||||||
if (isNaN(ms) || ms <= 0) {
|
if (isNaN(ms) || ms <= 0) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: `Invalid interval: "${args.schedule_value}". Must be positive milliseconds (e.g., "300000" for 5 min).` }],
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Invalid interval: "${args.schedule_value}". Must be positive milliseconds (e.g., "300000" for 5 min).`,
|
||||||
|
},
|
||||||
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else if (args.schedule_type === 'once') {
|
} else if (args.schedule_type === 'once') {
|
||||||
if (/[Zz]$/.test(args.schedule_value) || /[+-]\d{2}:\d{2}$/.test(args.schedule_value)) {
|
if (
|
||||||
|
/[Zz]$/.test(args.schedule_value) ||
|
||||||
|
/[+-]\d{2}:\d{2}$/.test(args.schedule_value)
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: `Timestamp must be local time without timezone suffix. Got "${args.schedule_value}" — use format like "2026-02-01T15:30:00".` }],
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Timestamp must be local time without timezone suffix. Got "${args.schedule_value}" — use format like "2026-02-01T15:30:00".`,
|
||||||
|
},
|
||||||
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const date = new Date(args.schedule_value);
|
const date = new Date(args.schedule_value);
|
||||||
if (isNaN(date.getTime())) {
|
if (isNaN(date.getTime())) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: `Invalid timestamp: "${args.schedule_value}". Use local time format like "2026-02-01T15:30:00".` }],
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Invalid timestamp: "${args.schedule_value}". Use local time format like "2026-02-01T15:30:00".`,
|
||||||
|
},
|
||||||
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-main groups can only schedule for themselves
|
// Non-main groups can only schedule for themselves
|
||||||
const targetJid = isMain && args.target_group_jid ? args.target_group_jid : chatJid;
|
const targetJid =
|
||||||
|
isMain && args.target_group_jid ? args.target_group_jid : chatJid;
|
||||||
|
|
||||||
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
||||||
@@ -174,7 +227,12 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
|
|||||||
writeIpcFile(TASKS_DIR, data);
|
writeIpcFile(TASKS_DIR, data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: `Task ${taskId} scheduled: ${args.schedule_type} - ${args.schedule_value}` }],
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Task ${taskId} scheduled: ${args.schedule_type} - ${args.schedule_value}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -297,9 +355,12 @@ server.tool(
|
|||||||
|
|
||||||
let pollSeconds: number;
|
let pollSeconds: number;
|
||||||
try {
|
try {
|
||||||
pollSeconds = normalizeWatchCiIntervalSeconds(args.poll_interval_seconds, {
|
pollSeconds = normalizeWatchCiIntervalSeconds(
|
||||||
ciProvider: args.ci_provider,
|
args.poll_interval_seconds,
|
||||||
});
|
{
|
||||||
|
ciProvider: args.ci_provider,
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -407,8 +468,7 @@ server.tool(
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text' as const,
|
type: 'text' as const,
|
||||||
text:
|
text: error instanceof Error ? error.message : String(error),
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
@@ -436,10 +496,7 @@ server.tool(
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text' as const,
|
type: 'text' as const,
|
||||||
text:
|
text: error instanceof Error ? error.message : String(error),
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: String(error),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
@@ -470,8 +527,7 @@ server.tool(
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text' as const,
|
type: 'text' as const,
|
||||||
text:
|
text: error instanceof Error ? error.message : String(error),
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
@@ -489,30 +545,56 @@ server.tool(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(tasksFile)) {
|
if (!fs.existsSync(tasksFile)) {
|
||||||
return { content: [{ type: 'text' as const, text: 'No scheduled tasks found.' }] };
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: 'text' as const, text: 'No scheduled tasks found.' },
|
||||||
|
],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const allTasks = JSON.parse(fs.readFileSync(tasksFile, 'utf-8'));
|
const allTasks = JSON.parse(fs.readFileSync(tasksFile, 'utf-8'));
|
||||||
|
|
||||||
const tasks = isMain
|
const tasks = isMain
|
||||||
? allTasks
|
? allTasks
|
||||||
: allTasks.filter((t: { groupFolder: string }) => t.groupFolder === groupFolder);
|
: allTasks.filter(
|
||||||
|
(t: { groupFolder: string }) => t.groupFolder === groupFolder,
|
||||||
|
);
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
return { content: [{ type: 'text' as const, text: 'No scheduled tasks found.' }] };
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: 'text' as const, text: 'No scheduled tasks found.' },
|
||||||
|
],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatted = tasks
|
const formatted = tasks
|
||||||
.map(
|
.map(
|
||||||
(t: { id: string; prompt: string; schedule_type: string; schedule_value: string; status: string; next_run: string }) =>
|
(t: {
|
||||||
|
id: string;
|
||||||
|
prompt: string;
|
||||||
|
schedule_type: string;
|
||||||
|
schedule_value: string;
|
||||||
|
status: string;
|
||||||
|
next_run: string;
|
||||||
|
}) =>
|
||||||
`- [${t.id}] ${t.prompt.slice(0, 50)}... (${t.schedule_type}: ${t.schedule_value}) - ${t.status}, next: ${t.next_run || 'N/A'}`,
|
`- [${t.id}] ${t.prompt.slice(0, 50)}... (${t.schedule_type}: ${t.schedule_value}) - ${t.status}, next: ${t.next_run || 'N/A'}`,
|
||||||
)
|
)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
return { content: [{ type: 'text' as const, text: `Scheduled tasks:\n${formatted}` }] };
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: 'text' as const, text: `Scheduled tasks:\n${formatted}` },
|
||||||
|
],
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: `Error reading tasks: ${err instanceof Error ? err.message : String(err)}` }],
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Error reading tasks: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -533,7 +615,14 @@ server.tool(
|
|||||||
|
|
||||||
writeIpcFile(TASKS_DIR, data);
|
writeIpcFile(TASKS_DIR, data);
|
||||||
|
|
||||||
return { content: [{ type: 'text' as const, text: `Task ${args.task_id} pause requested.` }] };
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Task ${args.task_id} pause requested.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -552,19 +641,36 @@ server.tool(
|
|||||||
|
|
||||||
writeIpcFile(TASKS_DIR, data);
|
writeIpcFile(TASKS_DIR, data);
|
||||||
|
|
||||||
return { content: [{ type: 'text' as const, text: `Task ${args.task_id} resume requested.` }] };
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Task ${args.task_id} resume requested.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
'cancel_task',
|
'cancel_task',
|
||||||
'Cancel and delete a scheduled task. If no task_id is provided, cancels the current task (for background watchers).',
|
'Cancel and delete a scheduled task. If no task_id is provided, cancels the current task (for background watchers).',
|
||||||
{ task_id: z.string().optional().describe('The task ID to cancel. Omit to cancel the current task.') },
|
{
|
||||||
|
task_id: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('The task ID to cancel. Omit to cancel the current task.'),
|
||||||
|
},
|
||||||
async (args: { task_id?: string }) => {
|
async (args: { task_id?: string }) => {
|
||||||
const resolvedId = args.task_id || runtimeTaskId;
|
const resolvedId = args.task_id || runtimeTaskId;
|
||||||
if (!resolvedId) {
|
if (!resolvedId) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: 'No task_id provided and no current task context available.' }],
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: 'No task_id provided and no current task context available.',
|
||||||
|
},
|
||||||
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -579,7 +685,11 @@ server.tool(
|
|||||||
|
|
||||||
writeIpcFile(TASKS_DIR, data);
|
writeIpcFile(TASKS_DIR, data);
|
||||||
|
|
||||||
return { content: [{ type: 'text' as const, text: `Task cancellation requested.` }] };
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: 'text' as const, text: `Task cancellation requested.` },
|
||||||
|
],
|
||||||
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -590,18 +700,37 @@ if (allowGenericScheduling) {
|
|||||||
{
|
{
|
||||||
task_id: z.string().describe('The task ID to update'),
|
task_id: z.string().describe('The task ID to update'),
|
||||||
prompt: z.string().optional().describe('New prompt for the task'),
|
prompt: z.string().optional().describe('New prompt for the task'),
|
||||||
schedule_type: z.enum(['cron', 'interval', 'once']).optional().describe('New schedule type'),
|
schedule_type: z
|
||||||
schedule_value: z.string().optional().describe('New schedule value (see schedule_task for format)'),
|
.enum(['cron', 'interval', 'once'])
|
||||||
|
.optional()
|
||||||
|
.describe('New schedule type'),
|
||||||
|
schedule_value: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('New schedule value (see schedule_task for format)'),
|
||||||
},
|
},
|
||||||
async (args: { task_id: string; prompt?: string; schedule_type?: 'cron' | 'interval' | 'once'; schedule_value?: string }) => {
|
async (args: {
|
||||||
|
task_id: string;
|
||||||
|
prompt?: string;
|
||||||
|
schedule_type?: 'cron' | 'interval' | 'once';
|
||||||
|
schedule_value?: string;
|
||||||
|
}) => {
|
||||||
// Validate schedule_value if provided
|
// Validate schedule_value if provided
|
||||||
if (args.schedule_type === 'cron' || (!args.schedule_type && args.schedule_value)) {
|
if (
|
||||||
|
args.schedule_type === 'cron' ||
|
||||||
|
(!args.schedule_type && args.schedule_value)
|
||||||
|
) {
|
||||||
if (args.schedule_value) {
|
if (args.schedule_value) {
|
||||||
try {
|
try {
|
||||||
CronExpressionParser.parse(args.schedule_value);
|
CronExpressionParser.parse(args.schedule_value);
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: `Invalid cron: "${args.schedule_value}".` }],
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Invalid cron: "${args.schedule_value}".`,
|
||||||
|
},
|
||||||
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -611,7 +740,12 @@ if (allowGenericScheduling) {
|
|||||||
const ms = parseInt(args.schedule_value, 10);
|
const ms = parseInt(args.schedule_value, 10);
|
||||||
if (isNaN(ms) || ms <= 0) {
|
if (isNaN(ms) || ms <= 0) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: `Invalid interval: "${args.schedule_value}".` }],
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Invalid interval: "${args.schedule_value}".`,
|
||||||
|
},
|
||||||
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -625,12 +759,21 @@ if (allowGenericScheduling) {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
if (args.prompt !== undefined) data.prompt = args.prompt;
|
if (args.prompt !== undefined) data.prompt = args.prompt;
|
||||||
if (args.schedule_type !== undefined) data.schedule_type = args.schedule_type;
|
if (args.schedule_type !== undefined)
|
||||||
if (args.schedule_value !== undefined) data.schedule_value = args.schedule_value;
|
data.schedule_type = args.schedule_type;
|
||||||
|
if (args.schedule_value !== undefined)
|
||||||
|
data.schedule_value = args.schedule_value;
|
||||||
|
|
||||||
writeIpcFile(TASKS_DIR, data);
|
writeIpcFile(TASKS_DIR, data);
|
||||||
|
|
||||||
return { content: [{ type: 'text' as const, text: `Task ${args.task_id} update requested.` }] };
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Task ${args.task_id} update requested.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -641,15 +784,42 @@ server.tool(
|
|||||||
|
|
||||||
If folder is omitted, the host generates one automatically.`,
|
If folder is omitted, the host generates one automatically.`,
|
||||||
{
|
{
|
||||||
jid: z.string().describe('The chat JID (e.g., "120363336345536173@g.us", "tg:-1001234567890", "dc:1234567890123456")'),
|
jid: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
'The chat JID (e.g., "120363336345536173@g.us", "tg:-1001234567890", "dc:1234567890123456")',
|
||||||
|
),
|
||||||
name: z.string().describe('Display name for the room'),
|
name: z.string().describe('Display name for the room'),
|
||||||
room_mode: z.enum(['single', 'tribunal']).default('single').describe('single=owner only, tribunal=owner/reviewer roles enabled'),
|
room_mode: z
|
||||||
owner_agent_type: z.enum(['claude-code', 'codex']).optional().describe('Preferred owner agent type for the room'),
|
.enum(['single', 'tribunal'])
|
||||||
folder: z.string().optional().describe('Optional folder override. If omitted, the host generates one automatically.'),
|
.default('single')
|
||||||
trigger: z.string().optional().describe('Optional canonical trigger label to store for the room'),
|
.describe('single=owner only, tribunal=owner/reviewer roles enabled'),
|
||||||
requires_trigger: z.boolean().optional().describe('Whether direct trigger mention is required'),
|
owner_agent_type: z
|
||||||
is_main: z.boolean().optional().describe('Whether this room is the privileged main control room'),
|
.enum(['claude-code', 'codex'])
|
||||||
work_dir: z.string().optional().describe('Optional working directory override'),
|
.optional()
|
||||||
|
.describe('Preferred owner agent type for the room'),
|
||||||
|
folder: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Optional folder override. If omitted, the host generates one automatically.',
|
||||||
|
),
|
||||||
|
trigger: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Optional canonical trigger label to store for the room'),
|
||||||
|
requires_trigger: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe('Whether direct trigger mention is required'),
|
||||||
|
is_main: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe('Whether this room is the privileged main control room'),
|
||||||
|
work_dir: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Optional working directory override'),
|
||||||
},
|
},
|
||||||
async (args: {
|
async (args: {
|
||||||
jid: string;
|
jid: string;
|
||||||
@@ -664,7 +834,12 @@ If folder is omitted, the host generates one automatically.`,
|
|||||||
}) => {
|
}) => {
|
||||||
if (!isMain) {
|
if (!isMain) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: 'Only the main group can assign rooms.' }],
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: 'Only the main group can assign rooms.',
|
||||||
|
},
|
||||||
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -686,7 +861,12 @@ If folder is omitted, the host generates one automatically.`,
|
|||||||
writeIpcFile(TASKS_DIR, data);
|
writeIpcFile(TASKS_DIR, data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: `Room "${args.name}" assignment requested.` }],
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Room "${args.name}" assignment requested.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export interface SendMessageIpcPayloadInput {
|
|||||||
text: string;
|
text: string;
|
||||||
sender?: string;
|
sender?: string;
|
||||||
senderRole?: string;
|
senderRole?: string;
|
||||||
|
runId?: string;
|
||||||
groupFolder: string;
|
groupFolder: string;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
}
|
}
|
||||||
@@ -16,6 +17,7 @@ export function buildSendMessageIpcPayload(
|
|||||||
text: input.text,
|
text: input.text,
|
||||||
sender: input.sender || undefined,
|
sender: input.sender || undefined,
|
||||||
senderRole: input.senderRole || undefined,
|
senderRole: input.senderRole || undefined,
|
||||||
|
runId: input.runId || undefined,
|
||||||
groupFolder: input.groupFolder,
|
groupFolder: input.groupFolder,
|
||||||
timestamp: input.timestamp || new Date().toISOString(),
|
timestamp: input.timestamp || new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ describe('agent runner IPC message payload', () => {
|
|||||||
text: 'hello',
|
text: 'hello',
|
||||||
sender: 'Reviewer',
|
sender: 'Reviewer',
|
||||||
senderRole: 'reviewer',
|
senderRole: 'reviewer',
|
||||||
|
runId: 'run-reviewer-1',
|
||||||
groupFolder: 'discord-review',
|
groupFolder: 'discord-review',
|
||||||
timestamp: '2026-04-04T13:45:00.000Z',
|
timestamp: '2026-04-04T13:45:00.000Z',
|
||||||
}),
|
}),
|
||||||
@@ -19,6 +20,7 @@ describe('agent runner IPC message payload', () => {
|
|||||||
text: 'hello',
|
text: 'hello',
|
||||||
sender: 'Reviewer',
|
sender: 'Reviewer',
|
||||||
senderRole: 'reviewer',
|
senderRole: 'reviewer',
|
||||||
|
runId: 'run-reviewer-1',
|
||||||
groupFolder: 'discord-review',
|
groupFolder: 'discord-review',
|
||||||
timestamp: '2026-04-04T13:45:00.000Z',
|
timestamp: '2026-04-04T13:45:00.000Z',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ describe('GroupQueue', () => {
|
|||||||
await vi.advanceTimersByTimeAsync(10);
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stores direct terminal text for the active run and clears it when the run ends', async () => {
|
it('stores direct terminal text for the active run, keeps a recent run record, and clears the active slot when the run ends', async () => {
|
||||||
let releaseRun!: (value: boolean) => void;
|
let releaseRun!: (value: boolean) => void;
|
||||||
let runId: string | undefined;
|
let runId: string | undefined;
|
||||||
const blocker = new Promise<boolean>((resolve) => {
|
const blocker = new Promise<boolean>((resolve) => {
|
||||||
@@ -155,6 +155,13 @@ describe('GroupQueue', () => {
|
|||||||
expect(
|
expect(
|
||||||
queue.getDirectTerminalDeliveryForRun('group1@g.us', runId!, 'reviewer'),
|
queue.getDirectTerminalDeliveryForRun('group1@g.us', runId!, 'reviewer'),
|
||||||
).toBe('DONE_WITH_CONCERNS\n리뷰 완료');
|
).toBe('DONE_WITH_CONCERNS\n리뷰 완료');
|
||||||
|
expect(
|
||||||
|
queue.hasRecordedDirectTerminalDeliveryForRun(
|
||||||
|
'group1@g.us',
|
||||||
|
runId!,
|
||||||
|
'reviewer',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
releaseRun(true);
|
releaseRun(true);
|
||||||
await vi.advanceTimersByTimeAsync(10);
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
@@ -165,6 +172,13 @@ describe('GroupQueue', () => {
|
|||||||
expect(
|
expect(
|
||||||
queue.getDirectTerminalDeliveryForRun('group1@g.us', runId!, 'reviewer'),
|
queue.getDirectTerminalDeliveryForRun('group1@g.us', runId!, 'reviewer'),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
queue.hasRecordedDirectTerminalDeliveryForRun(
|
||||||
|
'group1@g.us',
|
||||||
|
runId!,
|
||||||
|
'reviewer',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('force-terminates a lingering process after output was delivered', async () => {
|
it('force-terminates a lingering process after output was delivered', async () => {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ interface GroupState {
|
|||||||
runningTaskId: string | null;
|
runningTaskId: string | null;
|
||||||
currentRunId: string | null;
|
currentRunId: string | null;
|
||||||
directTerminalDeliveries: Map<string, string>;
|
directTerminalDeliveries: Map<string, string>;
|
||||||
|
recentDirectTerminalDeliveries: Map<string, Map<string, string>>;
|
||||||
pendingMessages: boolean;
|
pendingMessages: boolean;
|
||||||
pendingTasks: QueuedTask[];
|
pendingTasks: QueuedTask[];
|
||||||
process: ChildProcess | null;
|
process: ChildProcess | null;
|
||||||
@@ -62,6 +63,32 @@ interface GroupState {
|
|||||||
startedAt: number | null;
|
startedAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_RECORDED_DIRECT_TERMINAL_RUNS = 16;
|
||||||
|
|
||||||
|
function recordRecentDirectTerminalDelivery(
|
||||||
|
state: GroupState,
|
||||||
|
runId: string,
|
||||||
|
senderRole: string,
|
||||||
|
text: string,
|
||||||
|
): void {
|
||||||
|
const existing = state.recentDirectTerminalDeliveries.get(runId) ?? new Map();
|
||||||
|
existing.set(senderRole, text);
|
||||||
|
state.recentDirectTerminalDeliveries.delete(runId);
|
||||||
|
state.recentDirectTerminalDeliveries.set(runId, existing);
|
||||||
|
|
||||||
|
while (
|
||||||
|
state.recentDirectTerminalDeliveries.size >
|
||||||
|
MAX_RECORDED_DIRECT_TERMINAL_RUNS
|
||||||
|
) {
|
||||||
|
const oldestRunId =
|
||||||
|
state.recentDirectTerminalDeliveries.keys().next().value ?? null;
|
||||||
|
if (!oldestRunId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
state.recentDirectTerminalDeliveries.delete(oldestRunId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function transitionRunPhase(
|
function transitionRunPhase(
|
||||||
state: GroupState,
|
state: GroupState,
|
||||||
groupJid: string,
|
groupJid: string,
|
||||||
@@ -200,6 +227,7 @@ export class GroupQueue {
|
|||||||
runningTaskId: null,
|
runningTaskId: null,
|
||||||
currentRunId: null,
|
currentRunId: null,
|
||||||
directTerminalDeliveries: new Map(),
|
directTerminalDeliveries: new Map(),
|
||||||
|
recentDirectTerminalDeliveries: new Map(),
|
||||||
pendingMessages: false,
|
pendingMessages: false,
|
||||||
pendingTasks: [],
|
pendingTasks: [],
|
||||||
process: null,
|
process: null,
|
||||||
@@ -440,6 +468,12 @@ export class GroupQueue {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.directTerminalDeliveries.set(senderRole, text);
|
state.directTerminalDeliveries.set(senderRole, text);
|
||||||
|
recordRecentDirectTerminalDelivery(
|
||||||
|
state,
|
||||||
|
state.currentRunId,
|
||||||
|
senderRole,
|
||||||
|
text,
|
||||||
|
);
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
groupJid,
|
groupJid,
|
||||||
@@ -475,6 +509,26 @@ export class GroupQueue {
|
|||||||
return state.directTerminalDeliveries.get(senderRole) ?? null;
|
return state.directTerminalDeliveries.get(senderRole) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasRecordedDirectTerminalDeliveryForRun(
|
||||||
|
groupJid: string,
|
||||||
|
runId: string,
|
||||||
|
senderRole?: string | null,
|
||||||
|
): boolean {
|
||||||
|
const state = this.getGroup(groupJid);
|
||||||
|
if (!senderRole) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
state.currentRunId === runId &&
|
||||||
|
state.directTerminalDeliveries.has(senderRole)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
state.recentDirectTerminalDeliveries.get(runId)?.has(senderRole) ?? false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private clearPostCloseTimers(state: GroupState): void {
|
private clearPostCloseTimers(state: GroupState): void {
|
||||||
if (state.postCloseTermTimer) {
|
if (state.postCloseTermTimer) {
|
||||||
clearTimeout(state.postCloseTermTimer);
|
clearTimeout(state.postCloseTermTimer);
|
||||||
|
|||||||
19
src/index.ts
19
src/index.ts
@@ -371,13 +371,30 @@ async function main(): Promise<void> {
|
|||||||
editFormattedTrackedChannelMessage(channels, jid, messageId, rawText),
|
editFormattedTrackedChannelMessage(channels, jid, messageId, rawText),
|
||||||
});
|
});
|
||||||
startIpcWatcher({
|
startIpcWatcher({
|
||||||
sendMessage: async (jid, text, senderRole) => {
|
sendMessage: async (jid, text, senderRole, runId) => {
|
||||||
|
if (
|
||||||
|
runId &&
|
||||||
|
(senderRole === 'reviewer' || senderRole === 'arbiter') &&
|
||||||
|
queue.hasRecordedDirectTerminalDeliveryForRun(jid, runId, senderRole)
|
||||||
|
) {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
transition: 'ipc:skip-post-terminal',
|
||||||
|
chatJid: jid,
|
||||||
|
senderRole,
|
||||||
|
runId,
|
||||||
|
},
|
||||||
|
'Skipped IPC relay message because the run already emitted a direct terminal verdict',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const route = resolveChannelForDeliveryRole(channels, jid, senderRole);
|
const route = resolveChannelForDeliveryRole(channels, jid, senderRole);
|
||||||
if (!route.channel) throw new Error(`No channel for JID: ${jid}`);
|
if (!route.channel) throw new Error(`No channel for JID: ${jid}`);
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
transition: 'ipc:route',
|
transition: 'ipc:route',
|
||||||
chatJid: jid,
|
chatJid: jid,
|
||||||
|
runId: runId ?? null,
|
||||||
senderRole: senderRole ?? null,
|
senderRole: senderRole ?? null,
|
||||||
requestedRoleChannel: route.requestedRoleChannelName,
|
requestedRoleChannel: route.requestedRoleChannelName,
|
||||||
selectedChannel: route.selectedChannelName,
|
selectedChannel: route.selectedChannelName,
|
||||||
|
|||||||
@@ -543,6 +543,7 @@ describe('IPC message authorization', () => {
|
|||||||
chatJid: 'other@g.us',
|
chatJid: 'other@g.us',
|
||||||
text: 'review text',
|
text: 'review text',
|
||||||
senderRole: 'reviewer',
|
senderRole: 'reviewer',
|
||||||
|
runId: 'run-reviewer-ipc',
|
||||||
},
|
},
|
||||||
'other-group',
|
'other-group',
|
||||||
false,
|
false,
|
||||||
@@ -554,6 +555,7 @@ describe('IPC message authorization', () => {
|
|||||||
'other@g.us',
|
'other@g.us',
|
||||||
'review text',
|
'review text',
|
||||||
'reviewer',
|
'reviewer',
|
||||||
|
'run-reviewer-ipc',
|
||||||
);
|
);
|
||||||
expect(result).toEqual(
|
expect(result).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -572,6 +574,7 @@ describe('IPC message authorization', () => {
|
|||||||
chatJid: 'third@g.us',
|
chatJid: 'third@g.us',
|
||||||
text: 'review text',
|
text: 'review text',
|
||||||
senderRole: 'reviewer',
|
senderRole: 'reviewer',
|
||||||
|
runId: 'run-reviewer-ipc',
|
||||||
},
|
},
|
||||||
'other-group',
|
'other-group',
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface IpcDeps {
|
|||||||
jid: string,
|
jid: string,
|
||||||
text: string,
|
text: string,
|
||||||
senderRole?: string,
|
senderRole?: string,
|
||||||
|
runId?: string,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
nudgeScheduler?: () => void;
|
nudgeScheduler?: () => void;
|
||||||
roomBindings: () => Record<string, RegisteredGroup>;
|
roomBindings: () => Record<string, RegisteredGroup>;
|
||||||
@@ -51,6 +52,7 @@ export interface IpcMessagePayload {
|
|||||||
chatJid?: string;
|
chatJid?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
senderRole?: string;
|
senderRole?: string;
|
||||||
|
runId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IpcMessageForwardResult {
|
export interface IpcMessageForwardResult {
|
||||||
@@ -86,7 +88,7 @@ export async function forwardAuthorizedIpcMessage(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendMessage(msg.chatJid, msg.text, msg.senderRole);
|
await sendMessage(msg.chatJid, msg.text, msg.senderRole, msg.runId);
|
||||||
return {
|
return {
|
||||||
outcome: 'sent',
|
outcome: 'sent',
|
||||||
chatJid: msg.chatJid,
|
chatJid: msg.chatJid,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { _initTestDatabase } from './db.js';
|
import { _initTestDatabase, createPairedTask } from './db.js';
|
||||||
import {
|
import {
|
||||||
executeBotOnlyPairedFollowUpAction,
|
executeBotOnlyPairedFollowUpAction,
|
||||||
executePendingPairedTurn,
|
executePendingPairedTurn,
|
||||||
} from './message-runtime-flow.js';
|
} from './message-runtime-flow.js';
|
||||||
import {
|
import {
|
||||||
|
claimPairedTurnExecution,
|
||||||
resetPairedFollowUpScheduleState,
|
resetPairedFollowUpScheduleState,
|
||||||
schedulePairedFollowUpOnce,
|
schedulePairedFollowUpOnce,
|
||||||
type ScheduledPairedFollowUpIntentKind,
|
type ScheduledPairedFollowUpIntentKind,
|
||||||
@@ -123,6 +124,79 @@ describe('executeBotOnlyPairedFollowUpAction', () => {
|
|||||||
'Skipped duplicate paired pending turn requeue while task state was unchanged',
|
'Skipped duplicate paired pending turn requeue while task state was unchanged',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('skips inline finalize when the same finalize-owner turn revision was already claimed elsewhere', async () => {
|
||||||
|
const task: PairedTask = {
|
||||||
|
id: 'task-inline-finalize-dedup',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'codex-main',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
review_requested_at: '2026-03-30T00:00:00.000Z',
|
||||||
|
round_trip_count: 1,
|
||||||
|
status: 'merge_ready',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-03-30T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-30T00:05:00.000Z',
|
||||||
|
};
|
||||||
|
const executeTurn = vi.fn();
|
||||||
|
const log = {
|
||||||
|
info: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
createPairedTask(task as any);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
claimPairedTurnExecution({
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-existing-finalize-owner',
|
||||||
|
task,
|
||||||
|
intentKind: 'finalize-owner-turn',
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const result = await executeBotOnlyPairedFollowUpAction({
|
||||||
|
action: {
|
||||||
|
kind: 'inline-finalize',
|
||||||
|
task,
|
||||||
|
cursor: 42,
|
||||||
|
},
|
||||||
|
chatJid: 'group@test',
|
||||||
|
group: {
|
||||||
|
name: 'Test Group',
|
||||||
|
folder: 'test-group',
|
||||||
|
trigger: '@Andy',
|
||||||
|
added_at: '2026-03-30T00:00:00.000Z',
|
||||||
|
requiresTrigger: false,
|
||||||
|
agentType: 'codex',
|
||||||
|
},
|
||||||
|
runId: 'run-inline-finalize',
|
||||||
|
channel: {} as any,
|
||||||
|
log,
|
||||||
|
saveState: vi.fn(),
|
||||||
|
lastAgentTimestamps: {},
|
||||||
|
executeTurn,
|
||||||
|
schedulePairedFollowUp: vi.fn(() => true),
|
||||||
|
closeStdin: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(executeTurn).not.toHaveBeenCalled();
|
||||||
|
expect(log.info).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
chatJid: 'group@test',
|
||||||
|
taskId: 'task-inline-finalize-dedup',
|
||||||
|
taskStatus: 'merge_ready',
|
||||||
|
handoffMode: 'inline-finalize',
|
||||||
|
}),
|
||||||
|
'Skipped inline merge_ready finalize because the task revision was already claimed elsewhere',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('executePendingPairedTurn', () => {
|
describe('executePendingPairedTurn', () => {
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ import {
|
|||||||
} from './message-runtime-rules.js';
|
} from './message-runtime-rules.js';
|
||||||
import type { ExecuteTurnFn } from './message-runtime-types.js';
|
import type { ExecuteTurnFn } from './message-runtime-types.js';
|
||||||
import { buildPairedTurnIdentity } from './paired-turn-identity.js';
|
import { buildPairedTurnIdentity } from './paired-turn-identity.js';
|
||||||
import { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
|
import {
|
||||||
|
claimPairedTurnExecution,
|
||||||
|
type ScheduledPairedFollowUpIntentKind,
|
||||||
|
} from './paired-follow-up-scheduler.js';
|
||||||
import { hasReviewerLease } from './service-routing.js';
|
import { hasReviewerLease } from './service-routing.js';
|
||||||
import type {
|
import type {
|
||||||
Channel,
|
Channel,
|
||||||
@@ -398,6 +401,29 @@ export async function executeBotOnlyPairedFollowUpAction(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action.kind === 'inline-finalize') {
|
if (action.kind === 'inline-finalize') {
|
||||||
|
const claimed = claimPairedTurnExecution({
|
||||||
|
chatJid,
|
||||||
|
runId,
|
||||||
|
task: action.task,
|
||||||
|
intentKind: 'finalize-owner-turn',
|
||||||
|
});
|
||||||
|
if (!claimed) {
|
||||||
|
log.info(
|
||||||
|
{
|
||||||
|
chatJid,
|
||||||
|
group: group.name,
|
||||||
|
groupFolder: group.folder,
|
||||||
|
taskId: action.task.id,
|
||||||
|
taskStatus: action.task.status,
|
||||||
|
handoffMode: 'inline-finalize',
|
||||||
|
nextRole: 'owner',
|
||||||
|
cursor: action.cursor,
|
||||||
|
},
|
||||||
|
'Skipped inline merge_ready finalize because the task revision was already claimed elsewhere',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
{
|
{
|
||||||
chatJid,
|
chatJid,
|
||||||
|
|||||||
Reference in New Issue
Block a user