agents-relay 2.0.59 → 2.0.60

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 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
+ }
@@ -145,7 +145,20 @@ export async function withEventBus(args, work, resolve = eventBus) {
145
145
  return await work(bus);
146
146
  }
147
147
  finally {
148
- await bus?.close?.();
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, LayaCliDecisionEngine } from './decision.js';
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 { store, job, localFile } = await storeFor(args);
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
- await store.saveTask(task);
287
- const result = await dispatchTaskAction(store, { id: 'task.queue', jobId: job.id, taskId: id });
288
- Object.assign(task, result.task);
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: new LayaCliDecisionEngine(), 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) }); }
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().catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
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 !== 'RUNNING' || task.adapter === 'chatgpt' || !task.executionId || !task.leaseOwner || task.leaseOwner === ports.owner)
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 only the deterministic fallback when the decision engine fails.',
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('Choose one complete route from the merged choice list. Relay has already merged profile defaults with the user\'s explicit [choices] and applied every authoritative hard gate, so only these routes are legal. Do not invent, add, or substitute routes that are not listed.', routingInstruction);
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('Laya selected a route outside the merged choice list');
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',
@@ -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 }] : [],
@@ -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
- record.child.kill(signal);
33
- return true;
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 false;
37
+ return 'gone';
38
38
  audit(record, 'process.signal_failed', 'failed', { signal, error: error instanceof Error ? error.message : String(error) });
39
- return false;
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 sent = signalOwned(record, 'SIGKILL');
51
- audit(record, 'process.signal', sent ? 'succeeded' : 'failed', { reason, signal: 'SIGKILL' });
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 sent = signalOwned(record, 'SIGTERM');
147
- audit(record, 'process.signal', sent ? 'succeeded' : 'failed', { reason, signal: 'SIGTERM' });
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
  }
@@ -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
- 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 });
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
+ }
@@ -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
- const reviewData = event.data?.review && typeof event.data.review === 'object' ? event.data.review : undefined;
222
- const headSha = typeof reviewData?.headSha === 'string' ? reviewData.headSha : typeof event.data?.headSha === 'string' ? event.data.headSha : task?.reviewHeadSha;
223
- if (!task || !headSha || task.reviewHeadSha !== headSha)
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 rawIssues = Array.isArray(reviewData?.issues) ? reviewData.issues : [];
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 evidence = { summary: event.message || 'Review ' + outcome, data: { ...(event.data ?? {}), review: reviewData ?? { headSha }, headSha, outcome } };
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.clearTerminalTimer(taskId, executionId);
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);
@@ -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 });
@@ -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, LayaCliDecisionEngine } from './decision.js';
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: new LayaCliDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, reviewer: runtimeReviewer(), projectPathForJob: async (job) => resolveRepositoryProjectPath(repositoryPaths.get(job.repository) ?? await resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot), job.projectPath) });
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: new LayaCliDecisionEngine(),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.59",
3
+ "version": "2.0.60",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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