agents-relay 2.0.59 → 2.0.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters.js +1 -1
- package/dist/cli-shutdown.js +16 -0
- package/dist/cli-support.js +14 -1
- package/dist/cli.js +21 -8
- package/dist/dashboard.js +2 -0
- package/dist/decision.js +48 -0
- package/dist/execution-process-tracker.js +35 -0
- package/dist/execution-recovery.js +7 -2
- package/dist/execution-routing.js +40 -8
- package/dist/model-redecision.js +1 -1
- package/dist/owned-process.js +9 -9
- package/dist/planner-runtime.js +3 -1
- package/dist/planner.js +3 -1
- package/dist/process-identity.js +83 -0
- package/dist/projected-store.js +8 -0
- package/dist/reconciler-review.js +52 -15
- package/dist/reconciler-runtime.js +8 -3
- package/dist/reconciler.js +1 -1
- package/dist/relay-store.js +33 -0
- package/dist/relayd.js +7 -4
- package/dist/task-update.js +52 -0
- package/dist/terminal-event-watchdog.js +11 -0
- package/package.json +1 -1
- package/workflow/task-lifecycle.mmd +1 -0
package/dist/adapters.js
CHANGED
|
@@ -93,7 +93,7 @@ export class CodexAdapter {
|
|
|
93
93
|
}
|
|
94
94
|
});
|
|
95
95
|
});
|
|
96
|
-
execution = { id: randomUUID(), promise, launched, cancel: () => { void terminateOwnedProcess(child, 'cancel'); } };
|
|
96
|
+
execution = { id: randomUUID(), promise, launched, pid: child.pid, cancel: () => { void terminateOwnedProcess(child, 'cancel'); } };
|
|
97
97
|
execution.threadId = bufferedThreadId;
|
|
98
98
|
return execution;
|
|
99
99
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** One-shot CLI commands may have leaked transport handles after durable work ends. */
|
|
2
|
+
export async function runCliEntrypoint(main, options = {}) {
|
|
3
|
+
let exitCode = 0;
|
|
4
|
+
try {
|
|
5
|
+
await main();
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
9
|
+
exitCode = 1;
|
|
10
|
+
}
|
|
11
|
+
process.exitCode = exitCode;
|
|
12
|
+
if (options.serve)
|
|
13
|
+
return;
|
|
14
|
+
const timer = setTimeout(() => (options.forceExit ?? process.exit)(exitCode), options.graceMs ?? 2_000);
|
|
15
|
+
timer.unref();
|
|
16
|
+
}
|
package/dist/cli-support.js
CHANGED
|
@@ -145,7 +145,20 @@ export async function withEventBus(args, work, resolve = eventBus) {
|
|
|
145
145
|
return await work(bus);
|
|
146
146
|
}
|
|
147
147
|
finally {
|
|
148
|
-
|
|
148
|
+
if (bus?.close) {
|
|
149
|
+
let timer;
|
|
150
|
+
const deadline = new Promise(resolve => { timer = setTimeout(resolve, 1_500); });
|
|
151
|
+
try {
|
|
152
|
+
await Promise.race([bus.close(), deadline]);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
process.stderr.write('warning: event bus close failed: ' + (error instanceof Error ? error.message : String(error)) + '\n');
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
if (timer)
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
149
162
|
}
|
|
150
163
|
}
|
|
151
164
|
export async function publishWake(bus, job, taskId, message) { if (!bus)
|
package/dist/cli.js
CHANGED
|
@@ -14,11 +14,11 @@ import { CodexThreadContinuation, CommandContinuation, WebhookContinuation } fro
|
|
|
14
14
|
import { eventFor } from './events.js';
|
|
15
15
|
import { serveDashboard } from './dashboard.js';
|
|
16
16
|
import { codexAndZaiUsageRegistry } from './usage.js';
|
|
17
|
-
import { decisionMode,
|
|
17
|
+
import { decisionMode, executionDecisionEngine } from './decision.js';
|
|
18
18
|
import { discoverAgents } from './registry.js';
|
|
19
19
|
import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
|
|
20
20
|
import { transitionJobEvent } from './scheduler.js';
|
|
21
|
-
import { applyOrchestratorStartAction, applyOrchestratorCompleteAction, applyTaskHoldAction, dispatchTaskAction } from './task-actions.js';
|
|
21
|
+
import { applyOrchestratorStartAction, applyOrchestratorCompleteAction, applyTaskHoldAction, applyTaskQueueAction, dispatchTaskAction } from './task-actions.js';
|
|
22
22
|
import { applyJobActivateAction, applyJobCancelAction } from './job-actions.js';
|
|
23
23
|
import { resolveExecutionContext } from './execution-context.js';
|
|
24
24
|
import { defaultWorkspaceRoot, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
|
|
@@ -29,6 +29,7 @@ import { helpText, usage } from './cli-help.js';
|
|
|
29
29
|
import { parseAgentGraph } from './agent-graph.js';
|
|
30
30
|
import { runJobUnblockCommand, runTaskUnblockCommand } from './cli-unblock.js';
|
|
31
31
|
import { createJobEntity, createTaskEntity } from './workflow/entity-creation.js';
|
|
32
|
+
import { runCliEntrypoint } from './cli-shutdown.js';
|
|
32
33
|
import { shutdownOwnedProcesses } from './owned-process.js';
|
|
33
34
|
export { helpText } from './cli-help.js';
|
|
34
35
|
import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, githubStore, eventBus, publishWake, publishDependentUnblocked, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, jobTagFromArgs, requestManagedReconciliation, relayControlJob, relayTaskCommand, withEventBus } from './cli-support.js';
|
|
@@ -165,7 +166,9 @@ async function runTaskCommand(action, args) {
|
|
|
165
166
|
await runManagedTaskCommand(action, args);
|
|
166
167
|
return;
|
|
167
168
|
}
|
|
168
|
-
const
|
|
169
|
+
const loaded = await storeFor(args);
|
|
170
|
+
const { store, localFile } = loaded;
|
|
171
|
+
const job = managed && action === 'update' ? await relayControlJob(args, loaded.job.id) : loaded.job;
|
|
169
172
|
if (action === 'list') {
|
|
170
173
|
console.log(JSON.stringify(job.tasks, null, 2));
|
|
171
174
|
return;
|
|
@@ -241,6 +244,7 @@ async function runTaskCommand(action, args) {
|
|
|
241
244
|
console.log(JSON.stringify(outputTask, null, 2));
|
|
242
245
|
return;
|
|
243
246
|
}
|
|
247
|
+
const expectedUpdatedAt = task.updatedAt;
|
|
244
248
|
if (['RUNNING', 'UNBLOCKED', 'SUCCEEDED', 'CANCELLED'].includes(task.state))
|
|
245
249
|
throw new Error(`Task ${id} is ${task.state}; durable execution fields are immutable in this state`);
|
|
246
250
|
if (hasArg(args, '--adapter')) {
|
|
@@ -283,9 +287,13 @@ async function runTaskCommand(action, args) {
|
|
|
283
287
|
if (hasArg(args, '--draft') && task.state === 'QUEUED')
|
|
284
288
|
await applyTaskHoldAction(task);
|
|
285
289
|
if (hasArg(args, '--queue')) {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
290
|
+
if (managed)
|
|
291
|
+
await applyTaskQueueAction(job, task);
|
|
292
|
+
else {
|
|
293
|
+
await store.saveTask(task);
|
|
294
|
+
const result = await dispatchTaskAction(store, { id: 'task.queue', jobId: job.id, taskId: id });
|
|
295
|
+
Object.assign(task, result.task);
|
|
296
|
+
}
|
|
289
297
|
}
|
|
290
298
|
if (hasArg(args, '--max-attempts') && task.maxAttempts < task.attempt)
|
|
291
299
|
throw new Error('--max-attempts cannot be less than the current attempt count');
|
|
@@ -309,6 +317,11 @@ async function runTaskCommand(action, args) {
|
|
|
309
317
|
if (hasArg(args, '--reasoning') && !hasArg(args, '--timeout'))
|
|
310
318
|
task.timeoutMs = taskOverallTimeoutMs(task.storySize ?? 'M', task.routing.reasoning);
|
|
311
319
|
}
|
|
320
|
+
if (managed) {
|
|
321
|
+
const updated = await relayTaskCommand(args, { jobId: job.id, taskId: id, action: 'update', task, expectedUpdatedAt });
|
|
322
|
+
console.log(JSON.stringify(updated.tasks.find(item => item.id === id), null, 2));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
312
325
|
task.updatedAt = new Date().toISOString();
|
|
313
326
|
await store.saveTask(task);
|
|
314
327
|
await saveLocal(localFile, job);
|
|
@@ -458,7 +471,7 @@ async function runJobCommand(action, args) {
|
|
|
458
471
|
}
|
|
459
472
|
export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
|
|
460
473
|
export function runtimeReviewer() { return { requirements: { capabilities: ['model'] } }; }
|
|
461
|
-
export function runtime(store, args, bus, usageRegistry = codexAndZaiUsageRegistry()) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const policy = chatGptTabClosePolicy(args); const planner = runtimePlanner(args); return new Reconciler(store, { owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, usageSnapshotProvider: () => usageRegistry.snapshot(), decisionEngine:
|
|
474
|
+
export function runtime(store, args, bus, usageRegistry = codexAndZaiUsageRegistry()) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const policy = chatGptTabClosePolicy(args); const planner = runtimePlanner(args); return new Reconciler(store, { owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, usageSnapshotProvider: () => usageRegistry.snapshot(), decisionEngine: executionDecisionEngine(), decisionMode: decisionMode(), reviewer: runtimeReviewer(), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: async (job) => resolveRepositoryProjectPath(await resolveManagedProjectPath(job.repository, arg(args, '--project-path'), arg(args, '--workspace-root') || defaultWorkspaceRoot()), job.projectPath) }); }
|
|
462
475
|
export function dashboardJobAction(store, reconciler, action) {
|
|
463
476
|
return async (jobId) => {
|
|
464
477
|
const job = await store.load(jobId);
|
|
@@ -704,4 +717,4 @@ async function main() {
|
|
|
704
717
|
throw new Error(usage);
|
|
705
718
|
}
|
|
706
719
|
if (isEntrypoint(import.meta.url))
|
|
707
|
-
main
|
|
720
|
+
void runCliEntrypoint(main, { serve: process.argv[2] === 'serve' });
|
package/dist/dashboard.js
CHANGED
|
@@ -280,6 +280,8 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
280
280
|
let result;
|
|
281
281
|
if (body.action === 'create' && body.task && resolvedActions?.taskCreate)
|
|
282
282
|
result = await resolvedActions.taskCreate(requestedJobId, body.task);
|
|
283
|
+
else if (body.action === 'update' && isTaskId(requestedTaskId) && body.task && body.expectedUpdatedAt && resolvedActions?.taskUpdate)
|
|
284
|
+
result = await resolvedActions.taskUpdate(requestedJobId, requestedTaskId, body.task, body.expectedUpdatedAt);
|
|
283
285
|
else if (body.action === 'start' && isTaskId(requestedTaskId) && resolvedActions?.taskStart)
|
|
284
286
|
result = await resolvedActions.taskStart(requestedJobId, requestedTaskId);
|
|
285
287
|
else if (body.action === 'retry' && isTaskId(requestedTaskId) && resolvedActions?.taskRetry)
|
package/dist/decision.js
CHANGED
|
@@ -71,6 +71,54 @@ export function parseChoiceDecision(raw, contract) {
|
|
|
71
71
|
result.probabilities = parsedProbabilities;
|
|
72
72
|
return result;
|
|
73
73
|
}
|
|
74
|
+
/** OpenRouter Decisions API adapter; the choice contract remains engine-independent. */
|
|
75
|
+
export class JevDecisionEngine {
|
|
76
|
+
options;
|
|
77
|
+
name = 'openrouter-jev';
|
|
78
|
+
constructor(options = {}) {
|
|
79
|
+
this.options = options;
|
|
80
|
+
}
|
|
81
|
+
async decide(request, contract) {
|
|
82
|
+
validateDecisionRequest(request, contract);
|
|
83
|
+
const apiKey = this.options.apiKey ?? process.env.OPENROUTER_API_KEY;
|
|
84
|
+
if (!apiKey)
|
|
85
|
+
throw new Error('OPENROUTER_API_KEY is required for Jev');
|
|
86
|
+
const choices = Object.fromEntries(contract.choices.map((choice, index) => [`route_${index + 1}`, choice]));
|
|
87
|
+
const started = Date.now();
|
|
88
|
+
const response = await (this.options.fetcher ?? fetch)(this.options.endpoint ?? 'https://openrouter.ai/api/alpha/decisions', {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
model: this.options.model ?? 'typesafe/jev-1.13',
|
|
93
|
+
state: request.facts,
|
|
94
|
+
questions: { decision: { type: 'choice', instructions: contract.question, criteria: choices } },
|
|
95
|
+
}),
|
|
96
|
+
signal: AbortSignal.timeout(this.options.timeoutMs ?? 12_000),
|
|
97
|
+
});
|
|
98
|
+
if (!response.ok)
|
|
99
|
+
throw new Error(`Jev Decisions API returned HTTP ${response.status}`);
|
|
100
|
+
const raw = await response.json();
|
|
101
|
+
const envelope = raw;
|
|
102
|
+
const answer = envelope?.answers?.decision;
|
|
103
|
+
if (!answer || typeof answer.choice !== 'string' || !Object.hasOwn(choices, answer.choice)) {
|
|
104
|
+
throw new Error('Jev returned a choice outside the decision contract');
|
|
105
|
+
}
|
|
106
|
+
const mappedProbabilities = answer.probabilities && typeof answer.probabilities === 'object'
|
|
107
|
+
? Object.fromEntries(Object.entries(answer.probabilities)
|
|
108
|
+
.filter(([id]) => Object.hasOwn(choices, id)).map(([id, p]) => [choices[id], p]))
|
|
109
|
+
: undefined;
|
|
110
|
+
return {
|
|
111
|
+
...parseChoiceDecision({ choice: choices[answer.choice], probabilities: mappedProbabilities }, contract),
|
|
112
|
+
usage: raw.usage,
|
|
113
|
+
latencyMs: Date.now() - started,
|
|
114
|
+
model: raw.model,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Select router engine without changing caller-owned hard gates. */
|
|
119
|
+
export function executionDecisionEngine() {
|
|
120
|
+
return new JevDecisionEngine();
|
|
121
|
+
}
|
|
74
122
|
/**
|
|
75
123
|
* Stateless Laya invocation. The adapter materializes the caller's JSON state
|
|
76
124
|
* and question contract as temporary files for `uvx laya-mlx predict`. It is
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { activeRelayLeaseOwner, captureProcessRecord, probeProcessRecord, stopOrphanedProcessGroup } from './process-identity.js';
|
|
2
|
+
/** Correlate Relay execution IDs with OS process identity in SQLite, never GitHub. */
|
|
3
|
+
export async function monitorExecutionProcess(store, jobId, taskId, executionId, owner, execution) {
|
|
4
|
+
if (!execution.pid || !store.recordExecutionProcess)
|
|
5
|
+
return;
|
|
6
|
+
const registration = (async () => {
|
|
7
|
+
const record = await captureProcessRecord(jobId, taskId, executionId, owner, execution.pid);
|
|
8
|
+
if (record)
|
|
9
|
+
await store.recordExecutionProcess?.(record);
|
|
10
|
+
})();
|
|
11
|
+
const closeRecord = async () => {
|
|
12
|
+
await registration;
|
|
13
|
+
await store.finishExecutionProcess?.(jobId, taskId, executionId);
|
|
14
|
+
};
|
|
15
|
+
void execution.promise.then(closeRecord, closeRecord).catch(() => {
|
|
16
|
+
// Recording failures are surfaced by the registration promise to the caller.
|
|
17
|
+
});
|
|
18
|
+
await registration;
|
|
19
|
+
}
|
|
20
|
+
/** Reuse only if the previous exact process has gone or safely been reaped. */
|
|
21
|
+
export async function reclaimStaleExecutionProcess(store, task, currentOwner) {
|
|
22
|
+
if (!task.executionId || !task.jobId || !store.loadExecutionProcess)
|
|
23
|
+
return true;
|
|
24
|
+
const record = await store.loadExecutionProcess(task.jobId, task.id, task.executionId);
|
|
25
|
+
if (task.leaseOwner && task.leaseOwner !== currentOwner && await activeRelayLeaseOwner(task.leaseOwner))
|
|
26
|
+
return false;
|
|
27
|
+
if (!record || record.endedAt)
|
|
28
|
+
return true;
|
|
29
|
+
const state = await probeProcessRecord(record);
|
|
30
|
+
if (state === 'gone' || state === 'reused')
|
|
31
|
+
return true;
|
|
32
|
+
if (state !== 'alive' || (task.leaseOwner && task.leaseOwner !== currentOwner && await activeRelayLeaseOwner(task.leaseOwner)))
|
|
33
|
+
return false;
|
|
34
|
+
return await stopOrphanedProcessGroup(record);
|
|
35
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { transitionTaskEvent } from './scheduler.js';
|
|
2
|
+
import { reclaimStaleExecutionProcess } from './execution-process-tracker.js';
|
|
2
3
|
async function currentGeneration(store, job, stale) {
|
|
3
4
|
const task = (await store.load(job.id)).tasks.find(item => item.id === stale.id);
|
|
4
5
|
if (!task)
|
|
@@ -23,14 +24,18 @@ export async function recoverExpiredExecutions(job, now, ports) {
|
|
|
23
24
|
const recoverable = task && (task.state === 'RUNNING' || (task.state === 'QUEUED' && task.executionId !== null));
|
|
24
25
|
if (!recoverable || task.leaseExpiresAt === null || new Date(task.leaseExpiresAt).getTime() > now)
|
|
25
26
|
continue;
|
|
27
|
+
if (!await reclaimStaleExecutionProcess(ports.store, task, ports.owner))
|
|
28
|
+
continue;
|
|
26
29
|
await recoverTask(task, 'lease.expired', 'lease_expired', ports);
|
|
27
30
|
}
|
|
28
31
|
}
|
|
29
32
|
export async function recoverStartupOrphans(job, ports) {
|
|
30
|
-
const candidates = job.tasks.filter(task => task.kind !== 'planner' && task.state === 'RUNNING' && task.executionId && task.leaseOwner && task.leaseOwner !== ports.owner && task.adapter !== 'chatgpt');
|
|
33
|
+
const candidates = job.tasks.filter(task => task.kind !== 'planner' && (task.state === 'RUNNING' || task.state === 'QUEUED') && task.executionId && task.leaseOwner && task.leaseOwner !== ports.owner && task.adapter !== 'chatgpt');
|
|
31
34
|
for (const stale of candidates) {
|
|
32
35
|
const task = await currentGeneration(ports.store, job, stale);
|
|
33
|
-
if (!task || task.state
|
|
36
|
+
if (!task || !['RUNNING', 'QUEUED'].includes(task.state) || task.adapter === 'chatgpt' || !task.executionId || !task.leaseOwner || task.leaseOwner === ports.owner)
|
|
37
|
+
continue;
|
|
38
|
+
if (!await reclaimStaleExecutionProcess(ports.store, task, ports.owner))
|
|
34
39
|
continue;
|
|
35
40
|
await recoverTask(task, 'execution.orphaned', 'startup_orphaned', ports);
|
|
36
41
|
}
|
|
@@ -17,11 +17,35 @@ export function executionTaskShape(taskFacts = {}) {
|
|
|
17
17
|
: 'Small/medium tasks favor ChatGPT when Browser Workspace capacity is available.',
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
|
+
/** Explicit task capability tags override heuristic inference; legacy capabilities are additive. */
|
|
21
|
+
export function taskCapabilityFacts(taskFacts) {
|
|
22
|
+
const task = taskFacts.task && typeof taskFacts.task === 'object' ? taskFacts.task : taskFacts;
|
|
23
|
+
const explicit = task.taskCapabilities ?? task.task_capabilities;
|
|
24
|
+
const legacy = task.capabilities;
|
|
25
|
+
const tags = [...(Array.isArray(explicit) ? explicit : []), ...(Array.isArray(legacy) ? legacy : [])]
|
|
26
|
+
.filter((item) => typeof item === 'string' && item.trim().length > 0);
|
|
27
|
+
if (Array.isArray(explicit) && explicit.length > 0)
|
|
28
|
+
return [...new Set(tags)];
|
|
29
|
+
const description = [task.input, task.title, task.description, task.role].filter(item => typeof item === 'string').join(' ').toLowerCase();
|
|
30
|
+
if (/image generation|generate (an? )?image|生成图片|图片生成|生图/.test(description))
|
|
31
|
+
tags.push('image_generation');
|
|
32
|
+
if (/final review|final approval review|最终审核|最终审查|终审/.test(description))
|
|
33
|
+
tags.push('final_review');
|
|
34
|
+
if (/visual review|image review|视觉审核|图片审核|画面审核/.test(description))
|
|
35
|
+
tags.push('visual_review');
|
|
36
|
+
if (/vision|image understanding|图像理解|图片理解|看图/.test(description))
|
|
37
|
+
tags.push('vision');
|
|
38
|
+
return [...new Set(tags)];
|
|
39
|
+
}
|
|
40
|
+
function workerCapabilityFacts(worker) {
|
|
41
|
+
const specializations = worker.adapter === 'chatgpt' ? ['vision', 'image_generation', 'visual_review', 'final_review'] : ['coding', 'cli', 'filesystem'];
|
|
42
|
+
return [...new Set([...worker.capabilities, ...specializations])];
|
|
43
|
+
}
|
|
20
44
|
export const EXECUTION_ROUTING_PRINCIPLES = [
|
|
21
45
|
'Choose one complete executable route: worker plus provider/model/profile/reasoning.',
|
|
22
46
|
'ChatGPT with MacBridge and Codex have broadly overlapping execution capabilities.',
|
|
23
47
|
"Browser Workspace exhaustion is ChatGPT's primary hard execution constraint.",
|
|
24
|
-
'Codex is
|
|
48
|
+
'Codex is the deterministic fallback when Jev fails; no second decision model is invoked.',
|
|
25
49
|
];
|
|
26
50
|
function sameModelRoute(candidate, route) {
|
|
27
51
|
return (!route.provider || candidate.provider === route.provider)
|
|
@@ -110,7 +134,7 @@ async function decideStage(engine, name, facts, question, choices) {
|
|
|
110
134
|
const decision = await engine.decide({ facts }, contract);
|
|
111
135
|
if (decision.type !== 'choice')
|
|
112
136
|
throw new Error('Execution router expected a choice result for ' + name);
|
|
113
|
-
return { name, facts, contract, result: { choice: decision.choice, probabilities: decision.probabilities } };
|
|
137
|
+
return { name, facts, contract, result: { choice: decision.choice, probabilities: decision.probabilities, usage: decision.usage, latencyMs: decision.latencyMs, model: decision.model } };
|
|
114
138
|
}
|
|
115
139
|
function routeId(worker, model) {
|
|
116
140
|
return worker.workerId + '::' + model.id + (model.reasoning ? '@' + model.reasoning : '');
|
|
@@ -265,10 +289,13 @@ export async function routeExecution(workers, requirements, options = {}) {
|
|
|
265
289
|
source: isDefaultCandidate(route.model, discoveredDefaults.get(route.worker.workerId) ?? []) ? 'profile-default' : 'explicit-choice',
|
|
266
290
|
}));
|
|
267
291
|
const choiceLabels = routes.map((route, index) => executionChoiceLabel(route, effectiveChoices[index].default));
|
|
292
|
+
// Usage is keyed by worker/provider, not repeated for every route choice.
|
|
293
|
+
const usageByProvider = Object.fromEntries([...new Map(routes.map(route => [
|
|
294
|
+
route.worker.workerId + '::' + route.model.provider,
|
|
295
|
+
{ worker: route.worker.workerId, provider: route.model.provider, ...codePlan(route.model) },
|
|
296
|
+
])).entries()]);
|
|
268
297
|
const facts = JSON.parse(JSON.stringify({
|
|
269
298
|
context: {
|
|
270
|
-
job: taskFacts.job ?? null,
|
|
271
|
-
task: taskFacts.task ?? taskFacts,
|
|
272
299
|
task_shape: {
|
|
273
300
|
story_size: taskShape.storySize,
|
|
274
301
|
sustained_work: taskShape.sustainedWork,
|
|
@@ -280,14 +307,19 @@ export async function routeExecution(workers, requirements, options = {}) {
|
|
|
280
307
|
reviewer_diversity: taskFacts.reviewer_diversity,
|
|
281
308
|
} : null,
|
|
282
309
|
},
|
|
310
|
+
task_capabilities: taskCapabilityFacts(taskFacts),
|
|
311
|
+
task_worker_capabilities: (() => {
|
|
312
|
+
const task = taskFacts.task && typeof taskFacts.task === 'object' ? taskFacts.task : taskFacts;
|
|
313
|
+
return Array.isArray(task.workerCapabilities) ? task.workerCapabilities : [];
|
|
314
|
+
})(),
|
|
315
|
+
worker_capabilities: Object.fromEntries(workers.map(worker => [worker.workerId, workerCapabilityFacts(worker)])),
|
|
316
|
+
usage_by_provider: usageByProvider,
|
|
283
317
|
runtime: {
|
|
284
318
|
chatgpt_running: chatgptRunning,
|
|
285
319
|
preferred_chatgpt_concurrency: preferredChatgptConcurrency,
|
|
286
320
|
},
|
|
287
|
-
worker_candidates: workerProviderFacts(routes),
|
|
288
|
-
effective_choices: effectiveChoices,
|
|
289
321
|
}));
|
|
290
|
-
const question = appendRoutingInstruction('
|
|
322
|
+
const question = appendRoutingInstruction('User routing instructions have highest priority over default preferences, task size, runtime and resource signals. Candidate order is arbitrary and conveys no priority, ranking or preference. Do not favor earlier choices. Select an offered route that obeys the user instructions.\nUser routing instructions:\n' + routingInstruction, 'For vision, image generation, visual review, and final review tasks, prefer ChatGPT Worker when available and within capacity; for coding, CLI and filesystem tasks, prefer Codex. These are soft preferences subordinate to user rules. Choose exactly one listed route; do not invent routes.');
|
|
291
323
|
let selected = fallback;
|
|
292
324
|
let evidence = { mode: options.decisionMode ?? 'off', fallback: true };
|
|
293
325
|
const decisionStages = [];
|
|
@@ -297,7 +329,7 @@ export async function routeExecution(workers, requirements, options = {}) {
|
|
|
297
329
|
decisionStages.push(stage);
|
|
298
330
|
const index = stage.result.choice ? choiceLabels.indexOf(stage.result.choice) : -1;
|
|
299
331
|
if (index < 0)
|
|
300
|
-
throw new Error('
|
|
332
|
+
throw new Error('Decision engine selected a route outside the merged choice list');
|
|
301
333
|
selected = routes[index];
|
|
302
334
|
evidence = {
|
|
303
335
|
mode: options.decisionMode ?? 'active',
|
package/dist/model-redecision.js
CHANGED
|
@@ -31,7 +31,7 @@ export async function redecideFailedTask(options) {
|
|
|
31
31
|
rulesPath: options.rulesPath,
|
|
32
32
|
auditStore: options.auditStore,
|
|
33
33
|
auditTask: options.task,
|
|
34
|
-
taskFacts: { kind: options.task.kind ?? 'work', capabilities: options.task.capabilities, prior_routing: options.task.routing ?? null, fresh_usage_generated_at: usage.generatedAt },
|
|
34
|
+
taskFacts: { kind: options.task.kind ?? 'work', capabilities: options.task.capabilities, taskCapabilities: options.task.taskCapabilities, workerCapabilities: options.task.workerCapabilities, input: options.task.input, prior_routing: options.task.routing ?? null, fresh_usage_generated_at: usage.generatedAt },
|
|
35
35
|
priorDecisionId: options.task.routing?.decisionId,
|
|
36
36
|
priorFailure: options.evidence,
|
|
37
37
|
excludedRoutes: options.task.routing ? [{ provider: options.task.routing.provider, model: options.task.routing.model, profile: options.task.routing.profile }] : [],
|
package/dist/owned-process.js
CHANGED
|
@@ -28,15 +28,15 @@ function signalOwned(record, signal) {
|
|
|
28
28
|
try {
|
|
29
29
|
if (record.processGroup !== null)
|
|
30
30
|
process.kill(-record.processGroup, signal);
|
|
31
|
-
else
|
|
32
|
-
|
|
33
|
-
return
|
|
31
|
+
else if (!record.child.kill(signal))
|
|
32
|
+
return 'gone';
|
|
33
|
+
return 'sent';
|
|
34
34
|
}
|
|
35
35
|
catch (error) {
|
|
36
36
|
if (error.code === 'ESRCH')
|
|
37
|
-
return
|
|
37
|
+
return 'gone';
|
|
38
38
|
audit(record, 'process.signal_failed', 'failed', { signal, error: error instanceof Error ? error.message : String(error) });
|
|
39
|
-
return
|
|
39
|
+
return 'error';
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
function forceOwned(record, reason) {
|
|
@@ -47,8 +47,8 @@ function forceOwned(record, reason) {
|
|
|
47
47
|
audit(record, 'process.signal', result.status === 0 ? 'succeeded' : 'failed', { reason, signal: 'taskkill-/t-/f', exitStatus: result.status });
|
|
48
48
|
return;
|
|
49
49
|
}
|
|
50
|
-
const
|
|
51
|
-
audit(record, 'process.signal',
|
|
50
|
+
const outcome = signalOwned(record, 'SIGKILL');
|
|
51
|
+
audit(record, 'process.signal', outcome === 'error' ? 'failed' : 'succeeded', { reason, signal: 'SIGKILL', delivered: outcome === 'sent', alreadyGone: outcome === 'gone' });
|
|
52
52
|
}
|
|
53
53
|
function installExitHook() {
|
|
54
54
|
if (exitHookInstalled)
|
|
@@ -143,8 +143,8 @@ export async function terminateOwnedProcess(child, reason, graceMs) {
|
|
|
143
143
|
return;
|
|
144
144
|
if (!record.terminating) {
|
|
145
145
|
record.terminating = true;
|
|
146
|
-
const
|
|
147
|
-
audit(record, 'process.signal',
|
|
146
|
+
const outcome = signalOwned(record, 'SIGTERM');
|
|
147
|
+
audit(record, 'process.signal', outcome === 'error' ? 'failed' : 'succeeded', { reason, signal: 'SIGTERM', delivered: outcome === 'sent', alreadyGone: outcome === 'gone' });
|
|
148
148
|
record.forceTimer = setTimeout(() => forceOwned(record, reason), graceMs ?? record.graceMs);
|
|
149
149
|
record.forceTimer.unref();
|
|
150
150
|
}
|
package/dist/planner-runtime.js
CHANGED
|
@@ -58,7 +58,6 @@ async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
|
|
|
58
58
|
plan.plannerResult = result;
|
|
59
59
|
plan.assessment = result.assessment;
|
|
60
60
|
plan.generatedTaskIds = result.next_tasks.map(item => item.id);
|
|
61
|
-
plan.headSha = plan.trigger.currentHeadSha ?? await ports.store.headSha?.() ?? job.headSha;
|
|
62
61
|
plan.error = undefined;
|
|
63
62
|
plan.retryFeedback = undefined;
|
|
64
63
|
plan.leaseOwner = null;
|
|
@@ -101,6 +100,9 @@ export async function launchPlanExecution(ports, job, plan) {
|
|
|
101
100
|
const live = ports.live.get(fresh.id);
|
|
102
101
|
if (fresh.executionId && fresh.leaseOwner === ports.options.owner && live?.executionId === fresh.executionId)
|
|
103
102
|
return null;
|
|
103
|
+
fresh.headSha = await ports.store.headSha?.() ?? fresh.headSha;
|
|
104
|
+
if (fresh.headSha)
|
|
105
|
+
fresh.trigger = { ...fresh.trigger, currentHeadSha: fresh.headSha };
|
|
104
106
|
const startedAt = (ports.options.now ?? new Date()).getTime();
|
|
105
107
|
fresh.attempt = (fresh.attempt ?? 0) + 1;
|
|
106
108
|
fresh.maxAttempts ??= fresh.manualPlanning ? 1 : 3;
|
package/dist/planner.js
CHANGED
|
@@ -99,7 +99,9 @@ export function materializePlannerChild(job, planner, spec, executionContext) {
|
|
|
99
99
|
const decisionId = 'round' in planner ? planner.id : planner.id;
|
|
100
100
|
const source = 'round' in planner ? planner.source : planner.planningSource ?? 'model-planner';
|
|
101
101
|
const round = 'round' in planner ? planner.round : planner.planningRound ?? 1;
|
|
102
|
-
|
|
102
|
+
const plannerHeadSha = 'round' in planner ? planner.headSha : undefined;
|
|
103
|
+
const reviewHeadSha = spec.agentName === 'reviewer' ? plannerHeadSha ?? job.headSha : undefined;
|
|
104
|
+
return createTaskEntity(job, { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, review: spec.review, parentTaskId: spec.parentTaskId ?? null, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: spec.workerRequirements, adapter: 'codex', input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: decisionId, planningSource: source, planningRound: round, reviewHeadSha });
|
|
103
105
|
}
|
|
104
106
|
export function planningSpec(task) {
|
|
105
107
|
if (!task.output && task.adapter !== 'orchestrator')
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
/** ps lstart and PGID together guard against PID reuse after daemon restart. */
|
|
6
|
+
export async function observeProcess(pid) {
|
|
7
|
+
if (!Number.isSafeInteger(pid) || pid < 2)
|
|
8
|
+
return null;
|
|
9
|
+
try {
|
|
10
|
+
const { stdout } = await execFileAsync('ps', ['-p', String(pid), '-o', 'pgid=', '-o', 'lstart='], { timeout: 1500, maxBuffer: 2048 });
|
|
11
|
+
const match = stdout.trim().match(/^(\d+)\s+(.+)$/);
|
|
12
|
+
return match && Number(match[1]) > 0 ? { pgid: Number(match[1]), startToken: match[2].trim() } : null;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function captureProcessRecord(jobId, taskId, executionId, leaseOwner, pid) {
|
|
19
|
+
const snapshot = await observeProcess(pid);
|
|
20
|
+
return snapshot && snapshot.pgid === pid ? {
|
|
21
|
+
jobId, taskId, executionId, leaseOwner, host: hostname(), pid,
|
|
22
|
+
pgid: snapshot.pgid, startToken: snapshot.startToken, observedAt: new Date().toISOString(),
|
|
23
|
+
} : null;
|
|
24
|
+
}
|
|
25
|
+
export async function probeProcessRecord(record) {
|
|
26
|
+
if (record.host !== hostname() || !record.startToken || record.pid !== record.pgid)
|
|
27
|
+
return 'unknown';
|
|
28
|
+
const current = await observeProcess(record.pid);
|
|
29
|
+
if (current)
|
|
30
|
+
return current.pgid === record.pgid && current.startToken === record.startToken ? 'alive' : 'reused';
|
|
31
|
+
try {
|
|
32
|
+
process.kill(record.pid, 0);
|
|
33
|
+
return 'unknown';
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
return error.code === 'ESRCH' ? 'gone' : 'unknown';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** A separate still-running relay daemon must retain its live lease. */
|
|
40
|
+
export async function activeRelayLeaseOwner(owner) {
|
|
41
|
+
const match = owner.match(/^relayd-(\d+)$/);
|
|
42
|
+
if (!match || Number(match[1]) <= 1)
|
|
43
|
+
return false;
|
|
44
|
+
try {
|
|
45
|
+
const { stdout } = await execFileAsync('ps', ['-p', match[1], '-o', 'command='], { timeout: 1500, maxBuffer: 4096 });
|
|
46
|
+
return /\bagents-relayd\b/.test(stdout) || /(?:^|\/)relayd(?:-main)?\.js(?:\s|$)/.test(stdout);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
53
|
+
/** Never signal another PID generation, remote host, or non-leader process group. */
|
|
54
|
+
export async function stopOrphanedProcessGroup(record, graceMs = 750) {
|
|
55
|
+
let state = await probeProcessRecord(record);
|
|
56
|
+
if (state === 'gone' || state === 'reused')
|
|
57
|
+
return true;
|
|
58
|
+
if (state !== 'alive' || process.platform === 'win32')
|
|
59
|
+
return false;
|
|
60
|
+
try {
|
|
61
|
+
process.kill(-record.pgid, 'SIGTERM');
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error.code !== 'ESRCH')
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
await sleep(graceMs);
|
|
68
|
+
state = await probeProcessRecord(record);
|
|
69
|
+
if (state === 'gone' || state === 'reused')
|
|
70
|
+
return true;
|
|
71
|
+
if (state !== 'alive')
|
|
72
|
+
return false;
|
|
73
|
+
try {
|
|
74
|
+
process.kill(-record.pgid, 'SIGKILL');
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error.code !== 'ESRCH')
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
await sleep(100);
|
|
81
|
+
state = await probeProcessRecord(record);
|
|
82
|
+
return state === 'gone' || state === 'reused';
|
|
83
|
+
}
|
package/dist/projected-store.js
CHANGED
|
@@ -192,6 +192,14 @@ export class ProjectedDurableStore {
|
|
|
192
192
|
if (this.seededFacts)
|
|
193
193
|
void this.relay.savePullRequestFacts(this.seededFacts).catch(() => { });
|
|
194
194
|
}
|
|
195
|
+
// Process identities are operational SQLite records: never enqueue GitHub projection.
|
|
196
|
+
async recordExecutionProcess(record) { await this.relay.recordExecutionProcess(record); }
|
|
197
|
+
async loadExecutionProcess(jobId, taskId, executionId) {
|
|
198
|
+
return this.relay.loadExecutionProcess(jobId, taskId, executionId);
|
|
199
|
+
}
|
|
200
|
+
async finishExecutionProcess(jobId, taskId, executionId) {
|
|
201
|
+
await this.relay.finishExecutionProcess(jobId, taskId, executionId);
|
|
202
|
+
}
|
|
195
203
|
/** Ingest trusted GitHub records without echoing them back through the projection outbox. */
|
|
196
204
|
async hydrate(job) {
|
|
197
205
|
await this.relay.ingestJobSnapshot(structuredClone(job));
|
|
@@ -25,6 +25,44 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
25
25
|
noApprovedCurrentReview: !reviewTasks.some(task => task.state === 'SUCCEEDED' && job.review?.headSha === headSha && job.review.outcome === 'approved'),
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
|
+
static reviewRecord(value) {
|
|
29
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
30
|
+
}
|
|
31
|
+
static reviewHeadSha(event) {
|
|
32
|
+
const data = ReconcilerReviewBase.reviewRecord(event.data);
|
|
33
|
+
const outcome = ReconcilerReviewBase.reviewRecord(data.outcome);
|
|
34
|
+
const review = ReconcilerReviewBase.reviewRecord(data.review);
|
|
35
|
+
return [outcome.head_sha, outcome.headSha, review.head_sha, review.headSha, data.head_sha, data.headSha]
|
|
36
|
+
.find((value) => typeof value === 'string' && value.length > 0);
|
|
37
|
+
}
|
|
38
|
+
static reviewIssues(event) {
|
|
39
|
+
const data = ReconcilerReviewBase.reviewRecord(event.data);
|
|
40
|
+
const outcome = ReconcilerReviewBase.reviewRecord(data.outcome);
|
|
41
|
+
const review = ReconcilerReviewBase.reviewRecord(data.review);
|
|
42
|
+
const rawIssues = outcome.blocking_findings !== undefined ? outcome.blocking_findings
|
|
43
|
+
: outcome.blockingFindings !== undefined ? outcome.blockingFindings
|
|
44
|
+
: review.issues !== undefined ? review.issues
|
|
45
|
+
: data.issues;
|
|
46
|
+
if (!Array.isArray(rawIssues))
|
|
47
|
+
return [];
|
|
48
|
+
return rawIssues.map(item => {
|
|
49
|
+
if (typeof item === 'string')
|
|
50
|
+
return item.trim();
|
|
51
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
52
|
+
return '';
|
|
53
|
+
const value = item;
|
|
54
|
+
return (typeof value.summary === 'string' ? value.summary
|
|
55
|
+
: typeof value.message === 'string' ? value.message
|
|
56
|
+
: typeof value.finding === 'string' ? value.finding
|
|
57
|
+
: typeof value.title === 'string' ? value.title : '').trim();
|
|
58
|
+
}).filter(Boolean);
|
|
59
|
+
}
|
|
60
|
+
static normalizeReviewEvidence(event, task) {
|
|
61
|
+
const headSha = ReconcilerReviewBase.reviewHeadSha(event);
|
|
62
|
+
if (!headSha)
|
|
63
|
+
return null;
|
|
64
|
+
return { headSha, issues: ReconcilerReviewBase.reviewIssues(event) };
|
|
65
|
+
}
|
|
28
66
|
async cancelStaleReviewTask(job) {
|
|
29
67
|
const staleTask = job.review?.taskId ? job.tasks.find(task => task.id === job.review?.taskId) : undefined;
|
|
30
68
|
if (!staleTask || !['DRAFT', 'QUEUED', 'RUNNING'].includes(staleTask.state))
|
|
@@ -179,7 +217,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
179
217
|
id: task.id, kind: task.kind ?? 'work', storySize: task.storySize ?? 'M',
|
|
180
218
|
priority: task.priority ?? null, agentName: task.agentName ?? null,
|
|
181
219
|
parentTaskId: task.parentTaskId, dependencies: task.dependencies,
|
|
182
|
-
capabilities: task.capabilities, input: task.input,
|
|
220
|
+
capabilities: task.capabilities, taskCapabilities: task.taskCapabilities, workerCapabilities: task.workerCapabilities, input: task.input,
|
|
183
221
|
attachments: task.attachments ?? [], output: task.output ?? null,
|
|
184
222
|
state: task.state, attempt: task.attempt, maxAttempts: task.maxAttempts,
|
|
185
223
|
},
|
|
@@ -218,10 +256,14 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
218
256
|
async applyReviewEvent(event) {
|
|
219
257
|
const job = await this.store.load(event.job_id);
|
|
220
258
|
const task = job.tasks.find(item => item.id === event.task_id && item.agentName === 'reviewer');
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
259
|
+
if (!task)
|
|
260
|
+
return { applied: false, reconcile: false, reason: event.type + '.ignored' };
|
|
261
|
+
const normalizedEvidence = ReconcilerReviewBase.normalizeReviewEvidence(event, task);
|
|
262
|
+
if (!normalizedEvidence)
|
|
224
263
|
return { applied: false, reconcile: false, reason: event.type + '.ignored' };
|
|
264
|
+
const headSha = normalizedEvidence.headSha;
|
|
265
|
+
if (task.reviewHeadSha !== headSha)
|
|
266
|
+
return { applied: false, reconcile: false, reason: event.type + '.stale' };
|
|
225
267
|
const currentHeadSha = await this.store.headSha?.();
|
|
226
268
|
if ((currentHeadSha && currentHeadSha !== headSha) || (job.headSha && job.headSha !== headSha))
|
|
227
269
|
return { applied: false, reconcile: false, reason: event.type + '.stale' };
|
|
@@ -239,16 +281,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
239
281
|
const reviewDecision = await dispatchReviewLifecycle(job.review?.outcome, event.type, { autoMerge: job.autoMerge !== false }, async (actionId) => {
|
|
240
282
|
if (actionId === 'review.createFix') {
|
|
241
283
|
needsReconcile = true;
|
|
242
|
-
const
|
|
243
|
-
const issues = rawIssues.map(item => {
|
|
244
|
-
if (typeof item === 'string')
|
|
245
|
-
return item.trim();
|
|
246
|
-
if (item && typeof item === 'object') {
|
|
247
|
-
const value = item;
|
|
248
|
-
return (typeof value.summary === 'string' ? value.summary : typeof value.message === 'string' ? value.message : typeof value.title === 'string' ? value.title : '').trim();
|
|
249
|
-
}
|
|
250
|
-
return '';
|
|
251
|
-
}).filter(Boolean);
|
|
284
|
+
const issues = normalizedEvidence.issues;
|
|
252
285
|
if (issues.length === 0)
|
|
253
286
|
issues.push(event.message || 'Address the reviewer-requested changes');
|
|
254
287
|
const now = event.timestamp || new Date().toISOString();
|
|
@@ -275,7 +308,11 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
275
308
|
const outcome = reviewOutcome(reviewDecision.target);
|
|
276
309
|
if (!outcome)
|
|
277
310
|
return { applied: false, reconcile: false, reason: event.type + '.ignored' };
|
|
278
|
-
const
|
|
311
|
+
const reviewData = event.data?.review && typeof event.data.review === 'object' && !Array.isArray(event.data.review)
|
|
312
|
+
? event.data.review : undefined;
|
|
313
|
+
const evidence = { summary: event.message || 'Review ' + outcome, data: {
|
|
314
|
+
...(event.data ?? {}), review: { ...(reviewData ?? {}), headSha }, headSha, outcome, source: event.source,
|
|
315
|
+
} };
|
|
279
316
|
job.review = { outcome, headSha, taskId: task.id, evidence, updatedAt: event.timestamp || new Date().toISOString() };
|
|
280
317
|
job.updatedAt = job.review.updatedAt;
|
|
281
318
|
await this.store.saveJob(job);
|
|
@@ -6,6 +6,8 @@ import { WorkflowEffectExecutor } from './workflow/effects.js';
|
|
|
6
6
|
import { workflowEffectFailureEvent } from './workflow/contracts.js';
|
|
7
7
|
import { ReconcileCoordinator } from './reconcile-coordinator.js';
|
|
8
8
|
import { acceptedReportedTag, eventDataWithoutTag } from './task-tag.js';
|
|
9
|
+
import { rearmTerminalEventDeadline } from './terminal-event-watchdog.js';
|
|
10
|
+
import { monitorExecutionProcess } from './execution-process-tracker.js';
|
|
9
11
|
export function executionEventSource(jobId, taskId, executionId) { return `job/${jobId}/task/${taskId}/execution/${executionId}`; }
|
|
10
12
|
function eventSourceId(event) { return typeof event.source?.id === 'string' ? event.source.id : undefined; }
|
|
11
13
|
function executionIdFromSource(sourceId, jobId, taskId) {
|
|
@@ -224,6 +226,9 @@ export class ReconcilerRuntimeBase {
|
|
|
224
226
|
}
|
|
225
227
|
registerExecutionOwner(jobId, taskId, executionId) {
|
|
226
228
|
activeExecutionOwners.set(executionEventSource(jobId, taskId, executionId), this);
|
|
229
|
+
const live = this.live.get(taskId);
|
|
230
|
+
if (live)
|
|
231
|
+
this.detach(monitorExecutionProcess(this.store, jobId, taskId, executionId, this.options.owner, live.execution), jobId, taskId, 'execution PID correlation');
|
|
227
232
|
}
|
|
228
233
|
cancelExecutionGeneration(jobId, taskId, executionId) {
|
|
229
234
|
if (!executionId)
|
|
@@ -336,10 +341,10 @@ export class ReconcilerRuntimeBase {
|
|
|
336
341
|
const task = job.tasks.find(item => item.id === taskId);
|
|
337
342
|
if (!task || (task.executionId !== executionId && !terminalExecutionClaimed(jobId, taskId, executionId)))
|
|
338
343
|
return;
|
|
339
|
-
if (task.adapter !== 'chatgpt')
|
|
340
|
-
this.
|
|
344
|
+
if (task.adapter !== 'chatgpt' && task.executionId === executionId && live?.executionId === executionId)
|
|
345
|
+
rearmTerminalEventDeadline(this.terminalTimers, taskId, task.timeoutMs - (Date.now() - live.launchBarrier), this.options.postExitTerminalGraceMs ?? 15_000, () => this.detach(this.timeoutTask(jobId, taskId, executionId), jobId, taskId, 'post-exit terminal event timeout'));
|
|
341
346
|
await this.emit(eventFor(jobId, taskId, task.parentTaskId, task.adapter === 'chatgpt' ? 'task.process.async_exited' : 'task.process.exited', live?.execution.processExit ? (live.execution.processExit.exitCode === 0 ? 'succeeded' : 'failed') : (error ? 'failed' : 'succeeded'), result?.summary ?? (error ? 'Worker runtime exited with error' : 'Worker runtime exited'), 'orchestrator', { authoritative: false, waitingForTerminalEvent: true, exitCode: live?.execution.processExit?.exitCode ?? null,
|
|
342
|
-
signal: live?.execution.processExit?.signal ?? null, stderr: live?.execution.processExit?.stderr ?? '', processError: error }, { agent: 'relay', component: 'agents-relay' }));
|
|
347
|
+
signal: live?.execution.processExit?.signal ?? null, stderr: live?.execution.processExit?.stderr ?? '', processError: error, executionId, pid: live?.execution.pid ?? null }, { id: executionEventSource(jobId, taskId, executionId), agent: 'relay', component: 'agents-relay' }));
|
|
343
348
|
}
|
|
344
349
|
async timeoutTask(jobId, taskId, executionId) {
|
|
345
350
|
const live = this.live.get(taskId);
|
package/dist/reconciler.js
CHANGED
|
@@ -470,7 +470,7 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
470
470
|
await this.failLaunch(job.id, task.id, task.executionId, error instanceof Error ? error.message : String(error));
|
|
471
471
|
return;
|
|
472
472
|
}
|
|
473
|
-
void (execution.launched ?? Promise.resolve()).then(() => this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.process.launched', 'running', `Task ${task.id} process launched`, 'user', { adapter: adapter.id, model: task.routing?.model }, { agent: 'relay', component: 'agents-relay' }))).catch(error => this.failLaunch(job.id, task.id, durableExecutionId, error instanceof Error ? error.message : String(error), 'launch'));
|
|
473
|
+
void (execution.launched ?? Promise.resolve()).then(() => this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.process.launched', 'running', `Task ${task.id} process launched`, 'user', { adapter: adapter.id, model: task.routing?.model, pid: execution.pid ?? null, executionId: durableExecutionId }, { agent: 'relay', component: 'agents-relay' }))).catch(error => this.failLaunch(job.id, task.id, durableExecutionId, error instanceof Error ? error.message : String(error), 'launch'));
|
|
474
474
|
if (startAckSetup)
|
|
475
475
|
void startAckSetup.ack.finally(() => { void startAckSetup.stop(); }).catch(() => { });
|
|
476
476
|
this.live.set(task.id, { jobId: job.id, taskId: task.id, executionId: durableExecutionId, execution, controller, launchBarrier: startedAt, workerStarted: false, processSettled: false });
|
package/dist/relay-store.js
CHANGED
|
@@ -74,6 +74,19 @@ export class SQLiteRelayStore {
|
|
|
74
74
|
sequence INTEGER,
|
|
75
75
|
received_at TEXT NOT NULL
|
|
76
76
|
);
|
|
77
|
+
CREATE TABLE IF NOT EXISTS execution_process (
|
|
78
|
+
job_id TEXT NOT NULL,
|
|
79
|
+
task_id TEXT NOT NULL,
|
|
80
|
+
execution_id TEXT NOT NULL,
|
|
81
|
+
lease_owner TEXT NOT NULL,
|
|
82
|
+
host TEXT NOT NULL,
|
|
83
|
+
pid INTEGER NOT NULL,
|
|
84
|
+
pgid INTEGER NOT NULL,
|
|
85
|
+
start_token TEXT NOT NULL,
|
|
86
|
+
observed_at TEXT NOT NULL,
|
|
87
|
+
ended_at TEXT,
|
|
88
|
+
PRIMARY KEY(job_id,task_id,execution_id)
|
|
89
|
+
);
|
|
77
90
|
${ProjectionOutbox.createTableSql()}
|
|
78
91
|
${ProjectionOutbox.createIndexSql()}
|
|
79
92
|
`);
|
|
@@ -201,6 +214,26 @@ export class SQLiteRelayStore {
|
|
|
201
214
|
ON CONFLICT(id) DO UPDATE SET state=excluded.state,head_ref=excluded.head_ref,head_sha=excluded.head_sha,observed_at=excluded.observed_at
|
|
202
215
|
`).run(facts.state, facts.head?.ref ?? null, facts.head?.sha ?? null, facts.observedAt);
|
|
203
216
|
}
|
|
217
|
+
async recordExecutionProcess(record) {
|
|
218
|
+
const existing = await this.loadExecutionProcess(record.jobId, record.taskId, record.executionId);
|
|
219
|
+
if (existing && (existing.pid !== record.pid || existing.pgid !== record.pgid || existing.host !== record.host || existing.startToken !== record.startToken)) {
|
|
220
|
+
throw new Error(`Conflicting process identity for ${record.jobId}/${record.taskId}/${record.executionId}`);
|
|
221
|
+
}
|
|
222
|
+
this.db.prepare(`INSERT OR IGNORE INTO execution_process
|
|
223
|
+
(job_id,task_id,execution_id,lease_owner,host,pid,pgid,start_token,observed_at,ended_at)
|
|
224
|
+
VALUES(?,?,?,?,?,?,?,?,?,?)`).run(record.jobId, record.taskId, record.executionId, record.leaseOwner, record.host, record.pid, record.pgid, record.startToken, record.observedAt, record.endedAt ?? null);
|
|
225
|
+
}
|
|
226
|
+
async loadExecutionProcess(jobId, taskId, executionId) {
|
|
227
|
+
const row = this.db.prepare(`SELECT lease_owner,host,pid,pgid,start_token,observed_at,ended_at
|
|
228
|
+
FROM execution_process WHERE job_id=? AND task_id=? AND execution_id=?`).get(jobId, taskId, executionId);
|
|
229
|
+
return row ? { jobId, taskId, executionId, leaseOwner: row.lease_owner,
|
|
230
|
+
host: row.host, pid: row.pid, pgid: row.pgid, startToken: row.start_token,
|
|
231
|
+
observedAt: row.observed_at, endedAt: row.ended_at } : null;
|
|
232
|
+
}
|
|
233
|
+
async finishExecutionProcess(jobId, taskId, executionId, at = new Date().toISOString()) {
|
|
234
|
+
this.db.prepare('UPDATE execution_process SET ended_at=COALESCE(ended_at,?) WHERE job_id=? AND task_id=? AND execution_id=?')
|
|
235
|
+
.run(at, jobId, taskId, executionId);
|
|
236
|
+
}
|
|
204
237
|
async markEventProcessed(eventId, stream = null, sequence = null) {
|
|
205
238
|
const result = this.db.prepare(`
|
|
206
239
|
INSERT OR IGNORE INTO event_receipt(event_id,stream,sequence,received_at) VALUES(?,?,?,?)
|
package/dist/relayd.js
CHANGED
|
@@ -17,11 +17,12 @@ import { eventBusResolutionMessage, publishOrchestratorTaskStarted, publishOrche
|
|
|
17
17
|
import { eventFor } from './events.js';
|
|
18
18
|
import { applyJobActivateAction } from './job-actions.js';
|
|
19
19
|
import { applyOrchestratorStartAction, applyOrchestratorCompleteAction } from './task-actions.js';
|
|
20
|
+
import { managedTaskUpdateHandler } from './task-update.js';
|
|
20
21
|
import { dashboardJobSetState, dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
|
|
21
22
|
import { codexAndZaiUsageRegistry } from './usage.js';
|
|
22
23
|
import { defaultWorkspaceRoot, discoverWorkspaceRepositories, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
|
|
23
24
|
import { deriveJobRuntimeStatus } from './runtime-status.js';
|
|
24
|
-
import { decisionMode,
|
|
25
|
+
import { decisionMode, executionDecisionEngine } from './decision.js';
|
|
25
26
|
import { JsonlDecisionAuditStore } from './decision-audit.js';
|
|
26
27
|
import { summarizeRuntime } from './runtime-summary.js';
|
|
27
28
|
import { executionRouterRulesPath } from './routing-policy.js';
|
|
@@ -197,7 +198,7 @@ export async function runDaemon(argv) {
|
|
|
197
198
|
job, githubState: snapshot.facts.state, draft: false, mergedAt: null, closedAt: null, publication: null,
|
|
198
199
|
}));
|
|
199
200
|
}));
|
|
200
|
-
const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), usageSnapshotProvider: () => usageRegistry.snapshot(), runtimeSummaryProvider: () => ({ chatgptRunning: summarizeRuntime(cachedOverviews.map(item => item.job)).workers.chatgpt }), decisionEngine:
|
|
201
|
+
const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), usageSnapshotProvider: () => usageRegistry.snapshot(), runtimeSummaryProvider: () => ({ chatgptRunning: summarizeRuntime(cachedOverviews.map(item => item.job)).workers.chatgpt }), decisionEngine: executionDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, reviewer: runtimeReviewer(), projectPathForJob: async (job) => resolveRepositoryProjectPath(repositoryPaths.get(job.repository) ?? await resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot), job.projectPath) });
|
|
201
202
|
const refreshAll = async (full = false) => {
|
|
202
203
|
if (client.rateLimitStatus().limited)
|
|
203
204
|
return cachedOverviews;
|
|
@@ -404,6 +405,8 @@ export async function runDaemon(argv) {
|
|
|
404
405
|
cancel: workspaceJobAction('cancel'),
|
|
405
406
|
jobSetState: workspaceJobSetState,
|
|
406
407
|
taskCreate: workspaceTaskCreate,
|
|
408
|
+
taskUpdate: managedTaskUpdateHandler(jobId => { const row = cachedOverviews.find(item => item.job.id === jobId); if (!row)
|
|
409
|
+
throw new Error('Job not found'); return { store: runtimeStore(row.job.repository, row.job.prNumber), repository: row.job.repository, prNumber: row.job.prNumber }; }, (repository, prNumber, jobId) => pool.reconcileTarget(repository, prNumber, jobId, 'control.task.update')),
|
|
407
410
|
taskStart: workspaceTaskStart,
|
|
408
411
|
taskRetry: workspaceTaskAction('retry'),
|
|
409
412
|
taskCancel: workspaceTaskAction('cancel'),
|
|
@@ -424,7 +427,7 @@ export async function runDaemon(argv) {
|
|
|
424
427
|
{ name: 'chatgpt', id: 'chatgpt/browser-worker', capabilities: ['model', 'chatgpt', 'browser-workspace'] },
|
|
425
428
|
], [], usage);
|
|
426
429
|
const result = await routeExecution(workers, task.workerRequirements ?? { capabilities: task.capabilities ?? [] }, {
|
|
427
|
-
decisionEngine:
|
|
430
|
+
decisionEngine: executionDecisionEngine(),
|
|
428
431
|
decisionMode: 'active',
|
|
429
432
|
taskFacts: {
|
|
430
433
|
job: {
|
|
@@ -437,7 +440,7 @@ export async function runDaemon(argv) {
|
|
|
437
440
|
id: task.id, kind: task.kind ?? 'work', storySize: task.storySize ?? 'M',
|
|
438
441
|
priority: task.priority ?? null, agentName: task.agentName ?? null,
|
|
439
442
|
parentTaskId: task.parentTaskId, dependencies: task.dependencies,
|
|
440
|
-
capabilities: task.capabilities, input: task.input,
|
|
443
|
+
capabilities: task.capabilities, taskCapabilities: task.taskCapabilities, workerCapabilities: task.workerCapabilities, input: task.input,
|
|
441
444
|
attachments: task.attachments ?? [], output: task.output ?? null,
|
|
442
445
|
state: task.state, attempt: task.attempt, maxAttempts: task.maxAttempts,
|
|
443
446
|
},
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { applyTaskHoldAction, applyTaskQueueAction } from './task-actions.js';
|
|
2
|
+
const editableFields = [
|
|
3
|
+
'priority', 'storySize', 'projectName', 'agentName', 'agentGraph',
|
|
4
|
+
'parentTaskId', 'dependencies', 'capabilities', 'workerRequirements',
|
|
5
|
+
'adapter', 'chatgptTabClosePolicy', 'input', 'attachments',
|
|
6
|
+
'routing', 'timeoutMs', 'maxAttempts',
|
|
7
|
+
];
|
|
8
|
+
/** All managed CLI Task updates commit through the daemon's operational store. */
|
|
9
|
+
export async function applyManagedTaskUpdate(store, jobId, taskId, proposed, expectedUpdatedAt) {
|
|
10
|
+
const job = await store.load(jobId);
|
|
11
|
+
const current = job.tasks.find(item => item.id === taskId);
|
|
12
|
+
if (!current || proposed.id !== taskId || proposed.jobId !== jobId)
|
|
13
|
+
throw new Error('Task update identity mismatch');
|
|
14
|
+
if (current.updatedAt !== expectedUpdatedAt)
|
|
15
|
+
throw new Error('Task changed since update preflight; refresh and retry');
|
|
16
|
+
if (['RUNNING', 'UNBLOCKED', 'SUCCEEDED', 'CANCELLED'].includes(current.state) || current.executionId || current.leaseOwner)
|
|
17
|
+
throw new Error('Task is not mutable while active or terminal');
|
|
18
|
+
if (current.attempt > 0 && current.adapter !== proposed.adapter)
|
|
19
|
+
throw new Error('Cannot replace adapter after an execution attempt');
|
|
20
|
+
if (proposed.maxAttempts < current.attempt)
|
|
21
|
+
throw new Error('Retry budget is below execution attempts');
|
|
22
|
+
const next = structuredClone(current);
|
|
23
|
+
for (const key of editableFields) {
|
|
24
|
+
if (Object.hasOwn(proposed, key))
|
|
25
|
+
next[key] = structuredClone(proposed[key]);
|
|
26
|
+
else
|
|
27
|
+
delete next[key];
|
|
28
|
+
}
|
|
29
|
+
if (next.dependencies.includes(taskId))
|
|
30
|
+
throw new Error('Task cannot depend on itself');
|
|
31
|
+
if (proposed.state !== current.state) {
|
|
32
|
+
if (proposed.state === 'DRAFT' && current.state === 'QUEUED')
|
|
33
|
+
await applyTaskHoldAction(next);
|
|
34
|
+
else if (proposed.state === 'QUEUED')
|
|
35
|
+
await applyTaskQueueAction(job, next);
|
|
36
|
+
else
|
|
37
|
+
throw new Error('Task state change requires a dedicated lifecycle action');
|
|
38
|
+
}
|
|
39
|
+
if (next.adapter !== current.adapter || JSON.stringify(next.routing) !== JSON.stringify(current.routing))
|
|
40
|
+
next.workerSelection = undefined;
|
|
41
|
+
next.updatedAt = new Date().toISOString();
|
|
42
|
+
await store.saveTask(next);
|
|
43
|
+
return store.load(jobId);
|
|
44
|
+
}
|
|
45
|
+
export function managedTaskUpdateHandler(locate, wake) {
|
|
46
|
+
return async (jobId, taskId, task, expectedUpdatedAt) => {
|
|
47
|
+
const target = locate(jobId);
|
|
48
|
+
const updated = await applyManagedTaskUpdate(target.store, jobId, taskId, task, expectedUpdatedAt);
|
|
49
|
+
await wake(target.repository, target.prNumber, jobId);
|
|
50
|
+
return updated;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replace the overall worker deadline with a shorter post-exit deadline.
|
|
3
|
+
* Execution outcomes remain owned by the authoritative task lifecycle graph.
|
|
4
|
+
* Never extend the original overall timeout.
|
|
5
|
+
*/
|
|
6
|
+
export function rearmTerminalEventDeadline(timers, taskId, overallRemainingMs, graceMs, expire) {
|
|
7
|
+
const existing = timers.get(taskId);
|
|
8
|
+
if (existing)
|
|
9
|
+
clearTimeout(existing);
|
|
10
|
+
timers.set(taskId, setTimeout(expire, Math.max(1, Math.min(overallRemainingMs, graceMs))));
|
|
11
|
+
}
|
package/package.json
CHANGED
|
@@ -120,6 +120,7 @@ queued -->|on: worker.delivery_failed| failed
|
|
|
120
120
|
queued -->|on: worker.start_ack_failed| failed
|
|
121
121
|
queued -->|on: planner.unavailable| plannerUnavailableMark
|
|
122
122
|
plannerUnavailableMark --> failed
|
|
123
|
+
queued -->|on: execution.orphaned| orphanRetryAvailable
|
|
123
124
|
queued -->|on: lease.expired| retryAvailableQueued
|
|
124
125
|
retryAvailableQueued -->|true| leaseRetryQueuedClearError
|
|
125
126
|
leaseRetryQueuedClearError --> leaseRetryQueuedClearLease
|