agents-relay 2.0.141 → 2.0.143
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/cli-workflow-action.js +15 -0
- package/dist/cli-workflow.js +88 -0
- package/dist/cli.js +10 -0
- package/dist/dashboard-html.js +5 -5
- package/dist/dashboard-style.js +1 -0
- package/dist/escalation-notifier.js +92 -0
- package/dist/local-authority-store.js +29 -0
- package/dist/local-merge-delivery.js +33 -10
- package/dist/planner-child-recovery.js +44 -0
- package/dist/planner-recovery.js +30 -0
- package/dist/reconciler-review.js +9 -0
- package/dist/reconciler-runtime.js +7 -1
- package/dist/reconciler-workflow-outbox.js +11 -0
- package/dist/reconciler.js +31 -45
- package/dist/relay-job-snapshot-reads.js +11 -0
- package/dist/relay-store.js +28 -37
- package/dist/relay-workflow-operations.js +104 -0
- package/dist/relayd.js +2 -1
- package/dist/task-recovery.js +65 -15
- package/dist/troubleshooter-fallback.js +13 -5
- package/dist/workflow/action-ledger.js +75 -0
- package/dist/workflow/authoritative-conditions.js +38 -0
- package/dist/workflow/conditions.js +32 -0
- package/dist/workflow/definitions-v2.js +6 -1
- package/dist/workflow/dispatcher.js +53 -3
- package/dist/workflow/durable-publication.js +85 -0
- package/dist/workflow/durable-transition.js +9 -0
- package/dist/workflow/planner-orchestration-v2.js +15 -2
- package/dist/workflow/usage-index.js +35 -0
- package/package.json +9 -5
- package/workflow/main.mmd +54 -27
- package/workflow/planner-orchestration.mmd +21 -1
- package/workflow/review-lifecycle.mmd +4 -0
package/dist/reconciler.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { deliverPendingEscalations } from './escalation-notifier.js';
|
|
2
|
+
import { createFailedPlanRecovery } from './planner-recovery.js';
|
|
3
|
+
import { recoverPlannerChildren } from './planner-child-recovery.js';
|
|
4
|
+
import { drainProductionWorkflowOutbox } from './reconciler-workflow-outbox.js';
|
|
1
5
|
import { cancelPausedGenerations } from './reconciler-pause.js';
|
|
2
6
|
import { taskCommentContext } from './task-comments.js';
|
|
3
7
|
import { cancelActiveTasks } from './reconciler-cancellation.js';
|
|
@@ -11,7 +15,7 @@ import { applyTaskRetryAction } from './task-actions.js';
|
|
|
11
15
|
import { LaunchFailure, StartAckTimeout } from './adapters.js';
|
|
12
16
|
import { eventFor } from './events.js';
|
|
13
17
|
import { PreSubmissionRejection } from './worker-rejection.js';
|
|
14
|
-
import { createPlanRound,
|
|
18
|
+
import { createPlanRound, pendingPlan } from './planner.js';
|
|
15
19
|
import { retryPendingPlannerUnblocks } from './planner-runtime.js';
|
|
16
20
|
import { dispatchPlannerOrchestration, plannerOrchestrationFacts } from './workflow/planner-orchestration-v2.js';
|
|
17
21
|
import { resolveExecutionContext } from './execution-context.js';
|
|
@@ -27,7 +31,6 @@ import { taskUsesCanonicalProjectRun } from './workspace.js';
|
|
|
27
31
|
import { cleanupMergedJobWorktree } from './job-worktree-cleanup.js';
|
|
28
32
|
import { jobHasActiveExecution } from './job-execution-slot.js';
|
|
29
33
|
import { managedWorkerInput } from './managed-worker-input.js';
|
|
30
|
-
import { attachResolutionOwnership } from './workflow/task-resolution.js';
|
|
31
34
|
export { managedWorkerInput } from './managed-worker-input.js';
|
|
32
35
|
import { recoverExpiredExecutions, recoverStartupOrphans as recoverStartupExecutionOrphans } from './execution-recovery.js';
|
|
33
36
|
import { dispatchJobReconcile } from './workflow/job-reconcile-v2.js';
|
|
@@ -81,7 +84,10 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
81
84
|
return this.store.load(jobId);
|
|
82
85
|
this.reconciling = true;
|
|
83
86
|
try {
|
|
84
|
-
|
|
87
|
+
let job = await this.store.load(jobId);
|
|
88
|
+
await drainProductionWorkflowOutbox(this.store, this.options);
|
|
89
|
+
await deliverPendingEscalations(this.store, jobId, this.options.escalationNotifier);
|
|
90
|
+
job = await this.store.load(jobId);
|
|
85
91
|
for (const live of [...this.live.values()])
|
|
86
92
|
if (live.jobId === jobId && job.tasks.some(task => task.id === live.taskId && task.state === 'BLOCKED'))
|
|
87
93
|
this.cancelExecutionGeneration(jobId, live.taskId, live.executionId);
|
|
@@ -196,7 +202,9 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
196
202
|
await this.ensureFinalReview(finalState, headSha);
|
|
197
203
|
await this.dispatchExecutableTasks(jobId);
|
|
198
204
|
finalState = await this.store.load(jobId);
|
|
199
|
-
|
|
205
|
+
const localMergeCompleted = await reconcileLocalGitCompletion(this.store, finalState, headSha, this.options.noGitHub === true, this.options.projectPathForJob, event => this.emit(event), this.options.localMergeEffect, () => (this.options.now ?? new Date()).getTime());
|
|
206
|
+
await drainProductionWorkflowOutbox(this.store, this.options);
|
|
207
|
+
if (localMergeCompleted)
|
|
200
208
|
return this.store.load(jobId);
|
|
201
209
|
await dispatchJobReconcile(finalState, { jobActive: finalState.state === 'ACTIVE', implementationComplete: implementationComplete(finalState), headAvailable: Boolean(headSha), deliveryReady: finalState.localMerge?.status === 'succeeded' }, async (actionId) => {
|
|
202
210
|
const outcomes = await this.workflowEffects.execute([actionId], { jobId: finalState.id, transitionKey: `reconcile:${headSha ?? 'no-head'}`, data: { headSha } });
|
|
@@ -279,6 +287,16 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
279
287
|
changed = true;
|
|
280
288
|
return {};
|
|
281
289
|
}
|
|
290
|
+
if (actionId === 'planner.createRecovery') {
|
|
291
|
+
const failed = (job.plans ?? []).at(-1);
|
|
292
|
+
if (!failed || failed.status !== 'FAILED')
|
|
293
|
+
throw Error('Graph selected missing FAILED Plan recovery');
|
|
294
|
+
const recovery = createFailedPlanRecovery(job, failed);
|
|
295
|
+
await this.store.appendTask(recovery);
|
|
296
|
+
await this.emit(eventFor(job.id, recovery.id, null, 'troubleshooter.created', 'queued', `Troubleshooter recovery for failed Plan ${failed.id}`, 'orchestrator', { agentName: 'troubleshooter', planId: failed.id }));
|
|
297
|
+
changed = true;
|
|
298
|
+
return {};
|
|
299
|
+
}
|
|
282
300
|
if (actionId === 'planner.createRound') {
|
|
283
301
|
const plan = createPlanRound(job, { timeoutMs: this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M') });
|
|
284
302
|
await this.store.appendPlan(plan);
|
|
@@ -293,47 +311,7 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
293
311
|
return job;
|
|
294
312
|
return this.store.load(job.id);
|
|
295
313
|
}
|
|
296
|
-
async recoverPlannerChildren(job) {
|
|
297
|
-
for (const plan of (job.plans ?? []).filter(item => item.status === 'SUCCEEDED' && item.plannerResult)) {
|
|
298
|
-
const executionContext = await this.plannerContext(job, plan);
|
|
299
|
-
// Historical plans were validated on commit. Rechecking against later Task failures
|
|
300
|
-
// blocks recovery before the Troubleshooter graph can run.
|
|
301
|
-
const children = plan.plannerResult.next_tasks.map(spec => materializePlannerChild(job, plan, spec, executionContext));
|
|
302
|
-
for (const child of children) {
|
|
303
|
-
const existing = job.tasks.find(item => item.id === child.id);
|
|
304
|
-
if (existing)
|
|
305
|
-
continue;
|
|
306
|
-
const spec = plan.plannerResult.next_tasks.find(item => item.id === child.id);
|
|
307
|
-
if (spec?.resolution)
|
|
308
|
-
await this.store.saveTask(attachResolutionOwnership(job, child, spec.resolution, 'planner'));
|
|
309
|
-
await this.store.appendTask(child);
|
|
310
|
-
job.tasks.push(child);
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
const historicalPlanners = job.tasks.filter(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult);
|
|
314
|
-
if (historicalPlanners.length === 0)
|
|
315
|
-
return;
|
|
316
|
-
const executionContext = await this.plannerContext(job, historicalPlanners[0]);
|
|
317
|
-
for (const planner of historicalPlanners) {
|
|
318
|
-
const result = planner.plannerResult;
|
|
319
|
-
const plan = { jobId: job.id, id: planner.id, round: planner.planningRound ?? 1, source: planner.planningSource ?? 'model-planner', previousPlanId: planner.previousPlannerTaskId ?? null, trigger: planner.planTrigger ?? { source: 'continuation' }, status: 'SUCCEEDED', guidance: planner.plannerGuidance, objectiveStatus: result.objective_status, plannerResult: result, assessment: result.assessment, generatedTaskIds: result.next_tasks.map(item => item.id), headSha: planner.planTrigger?.currentHeadSha ?? job.headSha, attempt: planner.attempt, maxAttempts: planner.maxAttempts, leaseOwner: null, leaseExpiresAt: null, executionId: null, timeoutMs: planner.timeoutMs, manualPlanning: planner.manualPlanning, createdAt: planner.createdAt, updatedAt: planner.updatedAt };
|
|
320
|
-
if (!(job.plans ?? []).some(item => item.id === plan.id)) {
|
|
321
|
-
await this.store.appendPlan(plan);
|
|
322
|
-
(job.plans ??= []).push(plan);
|
|
323
|
-
}
|
|
324
|
-
else
|
|
325
|
-
continue;
|
|
326
|
-
const children = result.next_tasks.map(spec => materializePlannerChild(job, plan, spec, executionContext));
|
|
327
|
-
for (const child of children)
|
|
328
|
-
if (!job.tasks.some(item => item.id === child.id)) {
|
|
329
|
-
const spec = result.next_tasks.find(item => item.id === child.id);
|
|
330
|
-
if (spec?.resolution)
|
|
331
|
-
await this.store.saveTask(attachResolutionOwnership(job, child, spec.resolution, 'planner'));
|
|
332
|
-
await this.store.appendTask(child);
|
|
333
|
-
job.tasks.push(child);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
314
|
+
async recoverPlannerChildren(job) { return recoverPlannerChildren(this.store, job, (j, p) => this.plannerContext(j, p)); }
|
|
337
315
|
async resolvedContext(job, task, role, projectPathOverride) {
|
|
338
316
|
const projectPath = projectPathOverride ?? (this.options.projectPathForJob ? await this.options.projectPathForJob(job) : task.routing?.cwd ?? process.cwd());
|
|
339
317
|
return resolveExecutionContext(projectPath, role, {
|
|
@@ -443,6 +421,14 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
443
421
|
if (!claimedTask)
|
|
444
422
|
return;
|
|
445
423
|
task = claimedTask;
|
|
424
|
+
// After the durable execution claim, fence this Graph-selected launch
|
|
425
|
+
// before invoking the adapter. An Unknown previous attempt must never be
|
|
426
|
+
// re-launched speculatively. Legacy test stores have no SQLite ledger.
|
|
427
|
+
if (this.store.reserveWorkflowLaunch && task.executionId && task.leaseOwner) {
|
|
428
|
+
const reserved = await this.store.reserveWorkflowLaunch({ jobId: job.id, taskId: task.id, executionId: task.executionId, attempt: task.attempt, leaseOwner: task.leaseOwner });
|
|
429
|
+
if (!reserved)
|
|
430
|
+
throw new Error('already reserved launch attempt: reconcile existing generation');
|
|
431
|
+
}
|
|
446
432
|
const adapter = this.options.adapters.find(x => x.name === task.adapter);
|
|
447
433
|
if (!adapter) {
|
|
448
434
|
await this.failLaunch(job.id, task.id, task.executionId, 'No adapter ' + task.adapter);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { normalizeLegacyTaskLifecycle } from './types.js';
|
|
2
|
+
import { normalizeLegacyJobPrNumber } from './job-pr-number-migration.js';
|
|
3
|
+
function normalize(json) { const job = normalizeLegacyJobPrNumber(JSON.parse(json)); job.tasks = job.tasks.map(t => normalizeLegacyTaskLifecycle(t, job.executionMode)); return job; }
|
|
4
|
+
export function loadRelayJob(db, jobId) {
|
|
5
|
+
const row = db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(jobId);
|
|
6
|
+
return row?.json ? normalize(row.json) : null;
|
|
7
|
+
}
|
|
8
|
+
export function listRelayJobs(db, scopeJobId) {
|
|
9
|
+
const rows = db.prepare(scopeJobId ? 'SELECT json FROM job_snapshot WHERE job_id=? ORDER BY job_id' : 'SELECT json FROM job_snapshot ORDER BY job_id').all(...(scopeJobId ? [scopeJobId] : []));
|
|
10
|
+
return rows.map(row => normalize(row.json));
|
|
11
|
+
}
|
package/dist/relay-store.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
+
import { loadRelayJob, listRelayJobs } from './relay-job-snapshot-reads.js';
|
|
2
|
+
import { SQLiteWorkflowOperations } from './relay-workflow-operations.js';
|
|
1
3
|
import { preserveTaskComments } from './task-comments.js';
|
|
2
4
|
import { DatabaseSync } from 'node:sqlite';
|
|
3
5
|
import { dirname } from 'node:path';
|
|
4
6
|
import { mkdirSync } from 'node:fs';
|
|
5
|
-
import { normalizeLegacyTaskLifecycle } from './types.js';
|
|
6
7
|
import { recordPublicationInDatabase, loadPublicationFromDatabase, listPublicationEventsFromDatabase } from './publication-store.js';
|
|
7
8
|
import { observability } from './observability.js';
|
|
8
9
|
import { createJobIfAbsent } from './relay-create.js';
|
|
9
10
|
import { ensureUnifiedJobRegistry, createUnifiedJobIfAbsent } from './unified-job-registry.js';
|
|
10
11
|
import { migrateUnifiedRelaySchema } from './unified-relay-schema.js';
|
|
11
12
|
import { saveUnifiedPullRequestFacts } from './unified-pr-facts.js';
|
|
12
|
-
import { migrateLegacyJobPrNumbers
|
|
13
|
+
import { migrateLegacyJobPrNumbers } from './job-pr-number-migration.js';
|
|
13
14
|
import { hostInvocationTablesSql, recordHostInvocationEvent, hasHostInvocationDecision, recordHostInvocationDecision } from './host-invocation-store.js';
|
|
14
15
|
import { ProjectionOutbox, SCHEMA_VERSION, logicalOperationKey, } from './projection-outbox.js';
|
|
15
16
|
function newerRecord(local, remote) {
|
|
@@ -144,19 +145,8 @@ export class SQLiteRelayStore {
|
|
|
144
145
|
async listPublicationEvents(jobId) {
|
|
145
146
|
return listPublicationEventsFromDatabase(this.db, jobId);
|
|
146
147
|
}
|
|
147
|
-
async loadJob(jobId) {
|
|
148
|
-
|
|
149
|
-
const row = this.db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(jobId);
|
|
150
|
-
if (!row?.json)
|
|
151
|
-
return null;
|
|
152
|
-
const job = normalizeLegacyJobPrNumber(JSON.parse(row.json));
|
|
153
|
-
job.tasks = job.tasks.map(task => normalizeLegacyTaskLifecycle(task, job.executionMode));
|
|
154
|
-
return job;
|
|
155
|
-
}
|
|
156
|
-
async listJobs() {
|
|
157
|
-
const rows = this.db.prepare(this.scopeJobId ? 'SELECT json FROM job_snapshot WHERE job_id=? ORDER BY job_id' : 'SELECT json FROM job_snapshot ORDER BY job_id').all(...(this.scopeJobId ? [this.scopeJobId] : []));
|
|
158
|
-
return rows.map(row => { const job = normalizeLegacyJobPrNumber(JSON.parse(row.json)); job.tasks = job.tasks.map(task => normalizeLegacyTaskLifecycle(task, job.executionMode)); return job; });
|
|
159
|
-
}
|
|
148
|
+
async loadJob(jobId) { this.assertScope(jobId); return loadRelayJob(this.db, jobId); }
|
|
149
|
+
async listJobs() { return listRelayJobs(this.db, this.scopeJobId); }
|
|
160
150
|
async saveJob(job) {
|
|
161
151
|
if (!this.scopeJobId) {
|
|
162
152
|
this.writeJobSnapshot(job, new Date().toISOString());
|
|
@@ -172,6 +162,28 @@ export class SQLiteRelayStore {
|
|
|
172
162
|
throw error;
|
|
173
163
|
}
|
|
174
164
|
}
|
|
165
|
+
workflowOperations() {
|
|
166
|
+
return new SQLiteWorkflowOperations(this.db, (job, at) => this.writeJobSnapshot(job, at));
|
|
167
|
+
}
|
|
168
|
+
async commitWorkflowMutationAndEvents(jobId, mutation, events) {
|
|
169
|
+
this.assertScope(jobId);
|
|
170
|
+
return this.workflowOperations().commitWorkflowMutationAndEvents(jobId, mutation, events);
|
|
171
|
+
}
|
|
172
|
+
async commitWorkflowMutationWithEvents(jobId, mutation) {
|
|
173
|
+
this.assertScope(jobId);
|
|
174
|
+
return this.workflowOperations().commitWorkflowMutationWithEvents(jobId, mutation);
|
|
175
|
+
}
|
|
176
|
+
async reserveWorkflowLaunch(identity) {
|
|
177
|
+
this.assertScope(identity.jobId);
|
|
178
|
+
return this.workflowOperations().reserveWorkflowLaunch(identity);
|
|
179
|
+
}
|
|
180
|
+
async verifyWorkflowLaunch(jobId, taskId, executionId) {
|
|
181
|
+
this.assertScope(jobId);
|
|
182
|
+
return this.workflowOperations().verifyWorkflowLaunch(jobId, taskId, executionId);
|
|
183
|
+
}
|
|
184
|
+
async drainWorkflowEvents(publish) {
|
|
185
|
+
return this.workflowOperations().drainWorkflowEvents(publish);
|
|
186
|
+
}
|
|
175
187
|
async createJobIfAbsent(job) {
|
|
176
188
|
this.assertScope(job.id);
|
|
177
189
|
return this.scopeJobId
|
|
@@ -181,28 +193,7 @@ export class SQLiteRelayStore {
|
|
|
181
193
|
/** Atomic read/modify/write for local authoritative Task and Plan updates. */
|
|
182
194
|
async mutateJob(jobId, mutation) {
|
|
183
195
|
this.assertScope(jobId);
|
|
184
|
-
this.
|
|
185
|
-
try {
|
|
186
|
-
const row = this.db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(jobId);
|
|
187
|
-
if (!row?.json)
|
|
188
|
-
throw new Error(`Local authoritative Job ${jobId} not found`);
|
|
189
|
-
const current = normalizeLegacyJobPrNumber(JSON.parse(row.json));
|
|
190
|
-
current.tasks = current.tasks.map(task => normalizeLegacyTaskLifecycle(task, current.executionMode));
|
|
191
|
-
const before = JSON.stringify(current);
|
|
192
|
-
const next = mutation(current);
|
|
193
|
-
if (next.id !== jobId)
|
|
194
|
-
throw new Error('Cannot change authoritative Job identity');
|
|
195
|
-
if (JSON.stringify(next) !== before && next.updatedAt <= JSON.parse(before).updatedAt) {
|
|
196
|
-
next.updatedAt = monotonicJobTimestamp(JSON.parse(before).updatedAt);
|
|
197
|
-
}
|
|
198
|
-
this.writeJobSnapshot(next, new Date().toISOString());
|
|
199
|
-
this.db.exec('COMMIT');
|
|
200
|
-
return structuredClone(next);
|
|
201
|
-
}
|
|
202
|
-
catch (error) {
|
|
203
|
-
this.db.exec('ROLLBACK');
|
|
204
|
-
throw error;
|
|
205
|
-
}
|
|
196
|
+
return this.workflowOperations().mutateJob(jobId, mutation);
|
|
206
197
|
}
|
|
207
198
|
async ingestJobSnapshot(job) {
|
|
208
199
|
this.assertScope(job.id);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { normalizeLegacyTaskLifecycle } from './types.js';
|
|
2
|
+
import { normalizeLegacyJobPrNumber } from './job-pr-number-migration.js';
|
|
3
|
+
import { taskLifecycleV2 } from './workflow/definitions-v2.js';
|
|
4
|
+
import { dispatchWorkflowGraphSync } from './workflow/dispatcher.js';
|
|
5
|
+
import { WorkflowActionLedger } from './workflow/action-ledger.js';
|
|
6
|
+
import { ConditionRegistry, booleanCondition } from './workflow/conditions.js';
|
|
7
|
+
import { DurableWorkflowPublication } from './workflow/durable-publication.js';
|
|
8
|
+
function monotonicJobTimestamp(previous, candidate = new Date().toISOString()) { const a = Date.parse(previous), b = Date.parse(candidate); return new Date(Math.max(Number.isFinite(a) ? a + 1 : Date.now(), Number.isFinite(b) ? b : Date.now())).toISOString(); }
|
|
9
|
+
export class SQLiteWorkflowOperations {
|
|
10
|
+
db;
|
|
11
|
+
write;
|
|
12
|
+
constructor(db, write) {
|
|
13
|
+
this.db = db;
|
|
14
|
+
this.write = write;
|
|
15
|
+
}
|
|
16
|
+
async commitWorkflowMutationAndEvents(jobId, mutation, events) {
|
|
17
|
+
let committed;
|
|
18
|
+
new DurableWorkflowPublication(this.db).commitTransitionAndEvents(() => {
|
|
19
|
+
const row = this.db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(jobId);
|
|
20
|
+
if (!row?.json)
|
|
21
|
+
throw new Error(`Unknown Job ${jobId}`);
|
|
22
|
+
const current = normalizeLegacyJobPrNumber(JSON.parse(row.json));
|
|
23
|
+
current.tasks = current.tasks.map(task => normalizeLegacyTaskLifecycle(task, current.executionMode));
|
|
24
|
+
const next = mutation(current);
|
|
25
|
+
if (next.id !== jobId)
|
|
26
|
+
throw new Error('Cannot change authoritative Job identity');
|
|
27
|
+
if (next.state !== current.state && ['COMPLETED', 'CANCELLED'].includes(current.state))
|
|
28
|
+
throw new Error('Cannot overwrite terminal authoritative state');
|
|
29
|
+
const beforeTime = Date.parse(current.updatedAt);
|
|
30
|
+
if (Number.isFinite(beforeTime) && !(Date.parse(next.updatedAt) > beforeTime))
|
|
31
|
+
next.updatedAt = new Date(beforeTime + 1).toISOString();
|
|
32
|
+
this.write(next, new Date().toISOString());
|
|
33
|
+
committed = structuredClone(next);
|
|
34
|
+
}, () => events.map(item => ({ key: item.key, eventId: item.event.type, payload: item.event })));
|
|
35
|
+
return committed;
|
|
36
|
+
}
|
|
37
|
+
async commitWorkflowMutationWithEvents(jobId, mutation) {
|
|
38
|
+
const emitted = [];
|
|
39
|
+
return this.commitWorkflowMutationAndEvents(jobId, current => {
|
|
40
|
+
const result = mutation(current);
|
|
41
|
+
emitted.push(...result.events);
|
|
42
|
+
return result.next;
|
|
43
|
+
}, emitted);
|
|
44
|
+
}
|
|
45
|
+
async reserveWorkflowLaunch(identity) {
|
|
46
|
+
const decision = dispatchWorkflowGraphSync(taskLifecycleV2, { initialState: 'QUEUED', input: { transport: 'event', id: 'dispatch', payload: {} }, durableFacts: { dispatchable: true } });
|
|
47
|
+
if (!decision.accepted || !decision.actions.some(action => action.id === 'effect.launchTask'))
|
|
48
|
+
throw new Error('canonical Task Graph did not authorize launch');
|
|
49
|
+
return new WorkflowActionLedger(this.db).reserveForClaimedDispatch({
|
|
50
|
+
...identity, generation: identity.attempt, graph: 'task-lifecycle.mmd', actionId: 'effect.launchTask',
|
|
51
|
+
attemptKey: `${identity.jobId}/${identity.taskId}/${identity.executionId}/launch`,
|
|
52
|
+
}, decision, 'worker.started');
|
|
53
|
+
}
|
|
54
|
+
async verifyWorkflowLaunch(jobId, taskId, executionId) {
|
|
55
|
+
const ledger = new WorkflowActionLedger(this.db);
|
|
56
|
+
const key = `${jobId}/${taskId}/${executionId}/launch`;
|
|
57
|
+
if (!ledger.trace(key))
|
|
58
|
+
return 'Unknown';
|
|
59
|
+
const row = this.db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(jobId);
|
|
60
|
+
if (!row?.json)
|
|
61
|
+
return 'Unknown';
|
|
62
|
+
const job = JSON.parse(row.json);
|
|
63
|
+
const task = job.tasks.find(t => t.id === taskId);
|
|
64
|
+
// Only the canonical task.started transition persists RUNNING. The action
|
|
65
|
+
// did not set RUNNING at claim-time; therefore this state is a durable
|
|
66
|
+
// postcondition correlated to the exact execution generation.
|
|
67
|
+
const started = task?.state === 'RUNNING' && task.executionId === executionId
|
|
68
|
+
&& !!task.leaseOwner && task.executionHistory?.some(e => e.id === executionId && e.attempt === task.attempt);
|
|
69
|
+
const registry = new ConditionRegistry([booleanCondition('worker.started')]);
|
|
70
|
+
return ledger.verify(key, registry, { 'worker.started': started ? true : undefined }).outcome;
|
|
71
|
+
}
|
|
72
|
+
async drainWorkflowEvents(publish) {
|
|
73
|
+
return new DurableWorkflowPublication(this.db).replay(async (item) => {
|
|
74
|
+
const event = item.payload;
|
|
75
|
+
if (!event || event.event_id !== item.key || event.type !== item.eventId)
|
|
76
|
+
throw new Error('Outbox event identity mismatch');
|
|
77
|
+
await publish(event);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
async mutateJob(jobId, mutation) {
|
|
81
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
82
|
+
try {
|
|
83
|
+
const row = this.db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(jobId);
|
|
84
|
+
if (!row?.json)
|
|
85
|
+
throw new Error(`Local authoritative Job ${jobId} not found`);
|
|
86
|
+
const current = normalizeLegacyJobPrNumber(JSON.parse(row.json));
|
|
87
|
+
current.tasks = current.tasks.map(task => normalizeLegacyTaskLifecycle(task, current.executionMode));
|
|
88
|
+
const before = JSON.stringify(current);
|
|
89
|
+
const next = mutation(current);
|
|
90
|
+
if (next.id !== jobId)
|
|
91
|
+
throw new Error('Cannot change authoritative Job identity');
|
|
92
|
+
if (JSON.stringify(next) !== before && next.updatedAt <= JSON.parse(before).updatedAt) {
|
|
93
|
+
next.updatedAt = monotonicJobTimestamp(JSON.parse(before).updatedAt);
|
|
94
|
+
}
|
|
95
|
+
this.write(next, new Date().toISOString());
|
|
96
|
+
this.db.exec('COMMIT');
|
|
97
|
+
return structuredClone(next);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
this.db.exec('ROLLBACK');
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
package/dist/relayd.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { escalationNotifierFromEnvironment } from './escalation-notifier.js';
|
|
2
3
|
import { consumeMethodInjection, withTestInjection } from './e2e-injection.js';
|
|
3
4
|
import { once } from 'node:events';
|
|
4
5
|
import { spawnOwnedProcess } from './owned-process.js';
|
|
@@ -151,7 +152,7 @@ export async function runDaemon(argv) {
|
|
|
151
152
|
return dashboardManagedJobs(entries.filter(item => !['COMPLETED', 'CANCELLED'].includes(item.job.state) || counts.get(item.job.id) !== 1));
|
|
152
153
|
};
|
|
153
154
|
cachedOverviews = await inventoryFromLocal();
|
|
154
|
-
const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { noGitHub, 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(argv), projectPathForJob: async (job) => {
|
|
155
|
+
const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { noGitHub, 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, escalationNotifier: escalationNotifierFromEnvironment(), reviewer: runtimeReviewer(argv), projectPathForJob: async (job) => {
|
|
155
156
|
const canonical = repositoryPaths.get(job.repository) ?? await resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot);
|
|
156
157
|
if (!jobRequiresManagedWorktree(job))
|
|
157
158
|
return resolveRepositoryProjectPath(canonical, job.projectPath);
|
package/dist/task-recovery.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { eventFor } from './events.js';
|
|
2
3
|
import { tryTransitionTaskEvent } from './scheduler.js';
|
|
3
4
|
import { dispatchTaskResolutionSync, materializeResolution } from './workflow/task-resolution.js';
|
|
4
5
|
function activeRecovery(job, original) {
|
|
@@ -23,35 +24,47 @@ function validateRequest(request) {
|
|
|
23
24
|
export async function requestTroubleshooterEscalation(store, jobId, recoveryTaskId, request, now = new Date().toISOString()) {
|
|
24
25
|
validateRequest(request);
|
|
25
26
|
let result;
|
|
27
|
+
let requestedEvent;
|
|
26
28
|
const apply = (job) => {
|
|
27
29
|
const recovery = job.tasks.find(task => task.id === recoveryTaskId);
|
|
28
|
-
if (!recovery || recovery.agentName !== 'troubleshooter' || !recovery.parentTaskId)
|
|
30
|
+
if (!recovery || recovery.agentName !== 'troubleshooter' || (!recovery.parentTaskId && !recovery.recoveryPlanId))
|
|
29
31
|
throw new Error(`Task ${recoveryTaskId} is not a Troubleshooter recovery`);
|
|
30
32
|
if (job.state === 'CANCELLED' || job.state === 'COMPLETED')
|
|
31
33
|
throw new Error(`Job ${job.id} is terminal`);
|
|
32
|
-
const original = job.tasks.find(task => task.id === recovery.parentTaskId);
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
const original = recovery.parentTaskId ? job.tasks.find(task => task.id === recovery.parentTaskId) : undefined;
|
|
35
|
+
const originalPlan = recovery.recoveryPlanId ? job.plans?.find(plan => plan.id === recovery.recoveryPlanId) : undefined;
|
|
36
|
+
if ((!original || original.state !== 'FAILED') && (!originalPlan || originalPlan.status !== 'FAILED'))
|
|
37
|
+
throw new Error(`Troubleshooter ${recoveryTaskId} has no current FAILED original Task or Plan`);
|
|
35
38
|
if (recovery.escalation?.status === 'pending' && recovery.state === 'BLOCKED') {
|
|
36
39
|
result = { task: structuredClone(recovery), escalation: structuredClone(recovery.escalation), duplicate: true };
|
|
37
40
|
return job;
|
|
38
41
|
}
|
|
39
42
|
if (!['QUEUED', 'RUNNING', 'FAILED'].includes(recovery.state))
|
|
40
43
|
throw new Error(`Troubleshooter ${recoveryTaskId} cannot escalate from ${recovery.state}`);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
if (original) {
|
|
45
|
+
const proposal = { path: 'escalation_gate', targetTaskId: original.id, rootCause: request.context.trim(), solution: request.requiredAction.trim() };
|
|
46
|
+
const decision = dispatchTaskResolutionSync(original, proposal, 'troubleshooter', {
|
|
47
|
+
createEscalationGate: () => { original.resolution = materializeResolution(original, proposal, 'troubleshooter', now); },
|
|
48
|
+
});
|
|
49
|
+
if (!decision.accepted)
|
|
50
|
+
throw new Error(`Task resolution graph rejected escalation for ${original.id}`);
|
|
51
|
+
}
|
|
52
|
+
// A Plan is an independent durable entity, never a synthetic Task. The
|
|
53
|
+
// Plan-failure guard is owned by planner-orchestration; only the real
|
|
54
|
+
// Troubleshooter Task undergoes the task-lifecycle BLOCKED transition.
|
|
55
|
+
if (!tryTransitionTaskEvent(recovery, recovery.state === 'QUEUED' ? 'block' : 'task.blocked', { recoverableExecution: true }).accepted)
|
|
48
56
|
throw new Error(`Troubleshooter ${recoveryTaskId} cannot be blocked`);
|
|
49
57
|
const escalation = {
|
|
50
|
-
id: randomUUID(), originalTaskId: original
|
|
58
|
+
id: randomUUID(), originalTaskId: original?.id, originalPlanId: originalPlan?.id,
|
|
59
|
+
originalExecutionId: original?.executionId ?? undefined,
|
|
51
60
|
recoveryExecutionId: recovery.executionId ?? undefined, requiredAction: request.requiredAction.trim(),
|
|
52
61
|
resolutionCondition: request.resolutionCondition.trim(), context: request.context.trim(), status: 'pending',
|
|
53
|
-
requestedAt: now, notification: request.notification
|
|
62
|
+
requestedAt: now, notification: { status: 'pending', channel: typeof request.notification?.channel === 'string' ? request.notification.channel : 'relay-event', requestedAt: now },
|
|
54
63
|
};
|
|
64
|
+
const event = eventFor(job.id, recovery.id, original?.id ?? null, 'escalation.requested', 'blocked', `Human action required: ${escalation.requiredAction}`, 'user', { escalationId: escalation.id, originalTaskId: original?.id, originalPlanId: originalPlan?.id, requiredAction: escalation.requiredAction,
|
|
65
|
+
resolutionCondition: escalation.resolutionCondition, notificationStatus: 'pending' }, { agent: 'relay', component: 'agents-relay' });
|
|
66
|
+
event.event_id = `escalation/${escalation.id}`;
|
|
67
|
+
requestedEvent = event;
|
|
55
68
|
recovery.escalation = escalation;
|
|
56
69
|
recovery.error = `Waiting for human action: ${escalation.requiredAction}`;
|
|
57
70
|
recovery.failureReason = 'human_action_required';
|
|
@@ -61,8 +74,14 @@ export async function requestTroubleshooterEscalation(store, jobId, recoveryTask
|
|
|
61
74
|
job.updatedAt = now;
|
|
62
75
|
return job;
|
|
63
76
|
};
|
|
64
|
-
const job = store.
|
|
65
|
-
await store.
|
|
77
|
+
const job = store.commitWorkflowMutationWithEvents
|
|
78
|
+
? await store.commitWorkflowMutationWithEvents(jobId, current => {
|
|
79
|
+
const next = apply(current);
|
|
80
|
+
return { next, events: requestedEvent ? [{ key: requestedEvent.event_id, event: requestedEvent }] : [] };
|
|
81
|
+
})
|
|
82
|
+
: store.mutateJob ? await store.mutateJob(jobId, apply)
|
|
83
|
+
: await store.load(jobId).then(async (current) => { apply(current); for (const task of current.tasks)
|
|
84
|
+
await store.saveTask(task); await store.saveJob(current); return current; });
|
|
66
85
|
if (!result)
|
|
67
86
|
throw new Error('Escalation did not produce a durable result');
|
|
68
87
|
return { job, ...result };
|
|
@@ -103,3 +122,34 @@ export function hasPendingTroubleshooterEscalation(job, original) {
|
|
|
103
122
|
const recovery = activeRecovery(job, original);
|
|
104
123
|
return recovery?.state === 'BLOCKED' && recovery.escalation?.status === 'pending';
|
|
105
124
|
}
|
|
125
|
+
/** Trusted notification adapter receipt. Publishing to the bus does NOT count as
|
|
126
|
+
* delivery to a human. Only an actual downstream delivery ID can set delivered. */
|
|
127
|
+
export async function recordEscalationNotificationDelivery(store, jobId, recoveryTaskId, escalationId, receipt) {
|
|
128
|
+
if (!receipt.channel.trim() || !receipt.deliveryId.trim() || !Number.isFinite(Date.parse(receipt.deliveredAt)))
|
|
129
|
+
throw Error('Escalation delivery requires a real channel, provider receipt ID and timestamp');
|
|
130
|
+
if (!store.mutateJob)
|
|
131
|
+
throw Error('Escalation receipt requires authoritative SQLite mutation');
|
|
132
|
+
let confirmed;
|
|
133
|
+
await store.mutateJob(jobId, job => {
|
|
134
|
+
const recovery = job.tasks.find(task => task.id === recoveryTaskId && task.agentName === 'troubleshooter');
|
|
135
|
+
const escalation = recovery?.escalation;
|
|
136
|
+
if (!escalation || escalation.id !== escalationId)
|
|
137
|
+
throw Error('Unknown escalation delivery identity');
|
|
138
|
+
if (escalation.status !== 'pending')
|
|
139
|
+
throw Error('Escalation no longer pending');
|
|
140
|
+
const previous = escalation.notification;
|
|
141
|
+
if (previous?.status === 'delivered') {
|
|
142
|
+
if (previous.deliveryId !== receipt.deliveryId || previous.channel !== receipt.channel)
|
|
143
|
+
throw Error('Escalation delivery receipt conflict');
|
|
144
|
+
confirmed = structuredClone(escalation);
|
|
145
|
+
return job;
|
|
146
|
+
}
|
|
147
|
+
escalation.notification = { status: 'delivered', channel: receipt.channel, deliveryId: receipt.deliveryId, deliveredAt: receipt.deliveredAt };
|
|
148
|
+
recovery.updatedAt = receipt.deliveredAt;
|
|
149
|
+
confirmed = structuredClone(escalation);
|
|
150
|
+
return job;
|
|
151
|
+
});
|
|
152
|
+
if (!confirmed)
|
|
153
|
+
throw Error('Escalation delivery receipt not committed');
|
|
154
|
+
return confirmed;
|
|
155
|
+
}
|
|
@@ -8,7 +8,10 @@ export async function fallbackFailedTroubleshooters(store, jobId) {
|
|
|
8
8
|
const queued = [];
|
|
9
9
|
for (const recovery of job.tasks.filter(task => task.agentName === 'troubleshooter' && task.state === 'FAILED' && task.attempt >= task.maxAttempts && !task.input.includes(MARKER))) {
|
|
10
10
|
const original = job.tasks.find(task => task.id === recovery.parentTaskId);
|
|
11
|
-
|
|
11
|
+
const plan = job.plans?.find(item => item.id === recovery.recoveryPlanId);
|
|
12
|
+
if ((!original || original.state !== 'FAILED') && (!plan || plan.status !== 'FAILED'))
|
|
13
|
+
continue;
|
|
14
|
+
if ((original?.resolution?.path === 'same_job_dependency' && ['waiting', 'active'].includes(original.resolution.state)) || !RUNTIME_FAILURE.test(`${recovery.failureReason ?? ''} ${recovery.error ?? ''}`))
|
|
12
15
|
continue;
|
|
13
16
|
const update = (task) => {
|
|
14
17
|
if (task.state !== 'FAILED' || task.input.includes(MARKER))
|
|
@@ -48,12 +51,17 @@ export async function escalateExhaustedTroubleshooters(store, jobId) {
|
|
|
48
51
|
const escalated = [];
|
|
49
52
|
for (const recovery of job.tasks.filter(task => task.agentName === 'troubleshooter' && task.state === 'FAILED' && task.attempt >= task.maxAttempts && !task.escalation)) {
|
|
50
53
|
const original = job.tasks.find(task => task.id === recovery.parentTaskId);
|
|
51
|
-
|
|
54
|
+
const plan = job.plans?.find(item => item.id === recovery.recoveryPlanId);
|
|
55
|
+
if ((!original || original.state !== 'FAILED') && (!plan || plan.status !== 'FAILED'))
|
|
56
|
+
continue;
|
|
57
|
+
if (original?.resolution?.path === 'same_job_dependency' && ['waiting', 'active'].includes(original.resolution.state))
|
|
52
58
|
continue;
|
|
53
|
-
const
|
|
59
|
+
const subject = original ? `Task ${original.id}` : `Plan ${plan.id}`;
|
|
60
|
+
const failure = original?.error ?? original?.failureReason ?? plan?.error ?? 'unknown';
|
|
61
|
+
const context = `Automatic Troubleshooter ${recovery.id} could not produce a verified resolution after ${recovery.attempt} attempts. Last failure: ${recovery.error ?? recovery.failureReason ?? 'unknown'}. Original ${subject}: ${failure}.`;
|
|
54
62
|
const result = await requestTroubleshooterEscalation(store, jobId, recovery.id, {
|
|
55
|
-
requiredAction: `Inspect
|
|
56
|
-
resolutionCondition: `User supplies a corrective action or verified recovery instructions for
|
|
63
|
+
requiredAction: `Inspect ${subject} and Troubleshooter ${recovery.id}; correct provider/runtime or supply a concrete recovery decision and supporting evidence.`,
|
|
64
|
+
resolutionCondition: `User supplies a corrective action or verified recovery instructions for ${subject}.`, context,
|
|
57
65
|
});
|
|
58
66
|
if (!result.duplicate)
|
|
59
67
|
escalated.push(recovery.id);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export class WorkflowActionLedger {
|
|
2
|
+
database;
|
|
3
|
+
constructor(database) {
|
|
4
|
+
this.database = database;
|
|
5
|
+
database.exec(`CREATE TABLE IF NOT EXISTS workflow_action_trace (
|
|
6
|
+
attempt_key TEXT PRIMARY KEY, execution_id TEXT NOT NULL, generation INTEGER NOT NULL,
|
|
7
|
+
graph TEXT NOT NULL, action_id TEXT NOT NULL, condition_id TEXT NOT NULL,
|
|
8
|
+
outcome TEXT NOT NULL CHECK(outcome IN ('Unknown','Verified','Failed')),
|
|
9
|
+
observed_at TEXT NOT NULL
|
|
10
|
+
)`);
|
|
11
|
+
}
|
|
12
|
+
reserve(identity, decision, conditionId) {
|
|
13
|
+
if (!identity.executionId || !identity.attemptKey || !conditionId || !Number.isSafeInteger(identity.generation) || identity.generation < 0)
|
|
14
|
+
throw new Error('invalid action identity');
|
|
15
|
+
if (!decision.accepted || !decision.actions.some(action => action.id === identity.actionId))
|
|
16
|
+
throw new Error('action not authorized by graph decision');
|
|
17
|
+
const statement = this.database.prepare(`INSERT OR IGNORE INTO workflow_action_trace
|
|
18
|
+
(attempt_key,execution_id,generation,graph,action_id,condition_id,outcome,observed_at)
|
|
19
|
+
VALUES (?,?,?,?,?,?,'Unknown',?)`);
|
|
20
|
+
const inserted = statement.run(identity.attemptKey, identity.executionId, identity.generation, identity.graph, identity.actionId, conditionId, new Date().toISOString()).changes === 1;
|
|
21
|
+
if (!inserted) {
|
|
22
|
+
const old = this.trace(identity.attemptKey);
|
|
23
|
+
if (!old || old.executionId !== identity.executionId || old.generation !== identity.generation || old.graph !== identity.graph || old.actionId !== identity.actionId || old.conditionId !== conditionId)
|
|
24
|
+
throw new Error('attempt key collision: inconsistent action identity');
|
|
25
|
+
}
|
|
26
|
+
return inserted;
|
|
27
|
+
}
|
|
28
|
+
/** Authoritative, fail-closed reservation against the durable job_snapshot row. */
|
|
29
|
+
reserveForRunningTask(identity, decision, conditionId, now = new Date().toISOString(), allowedState = 'RUNNING') {
|
|
30
|
+
this.database.exec('BEGIN IMMEDIATE');
|
|
31
|
+
try {
|
|
32
|
+
const row = this.database.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(identity.jobId);
|
|
33
|
+
if (!row?.json)
|
|
34
|
+
throw new Error('authoritative job snapshot unavailable');
|
|
35
|
+
const job = JSON.parse(row.json);
|
|
36
|
+
const task = job.tasks?.find(item => item.id === identity.taskId);
|
|
37
|
+
if (!task || task.state !== allowedState || task.executionId !== identity.executionId || task.leaseOwner !== identity.leaseOwner || task.attempt !== identity.attempt || task.attempt !== identity.generation || !task.executionHistory?.some(entry => entry.id === identity.executionId && entry.attempt === identity.generation) || Boolean(job.pause) || !task.leaseExpiresAt || !Number.isFinite(Date.parse(task.leaseExpiresAt)) || !Number.isFinite(Date.parse(now)) || Date.parse(task.leaseExpiresAt) <= Date.parse(now)) {
|
|
38
|
+
throw new Error('stale execution, owner, attempt or lease: action fenced');
|
|
39
|
+
}
|
|
40
|
+
const created = this.reserve(identity, decision, conditionId);
|
|
41
|
+
this.database.exec('COMMIT');
|
|
42
|
+
return created;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
this.database.exec('ROLLBACK');
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** A dispatch launch is authorized while the Task is QUEUED with a claimed
|
|
50
|
+
* execution lease; task.started later transitions it to RUNNING. */
|
|
51
|
+
reserveForClaimedDispatch(identity, decision, conditionId) {
|
|
52
|
+
if (!decision.accepted || decision.source !== 'QUEUED' || decision.target !== 'QUEUED'
|
|
53
|
+
|| identity.actionId !== 'effect.launchTask')
|
|
54
|
+
throw Error('dispatch reservation requires canonical QUEUED Graph launch decision');
|
|
55
|
+
return this.reserveForRunningTask(identity, decision, conditionId, new Date().toISOString(), 'QUEUED');
|
|
56
|
+
}
|
|
57
|
+
trace(attemptKey) {
|
|
58
|
+
const row = this.database.prepare('SELECT * FROM workflow_action_trace WHERE attempt_key=?').get(attemptKey);
|
|
59
|
+
if (!row)
|
|
60
|
+
return null;
|
|
61
|
+
return { attemptKey: String(row.attempt_key), executionId: String(row.execution_id), generation: Number(row.generation), graph: String(row.graph), actionId: String(row.action_id), conditionId: String(row.condition_id), outcome: row.outcome, observedAt: String(row.observed_at) };
|
|
62
|
+
}
|
|
63
|
+
verify(attemptKey, conditions, facts) {
|
|
64
|
+
const existing = this.trace(attemptKey);
|
|
65
|
+
if (!existing)
|
|
66
|
+
throw new Error('unreserved action attempt');
|
|
67
|
+
if (existing.outcome !== 'Unknown')
|
|
68
|
+
return existing;
|
|
69
|
+
const outcome = conditions.check(existing.conditionId, facts);
|
|
70
|
+
// Unknown is intentionally retained: no second executor may treat it as a new attempt.
|
|
71
|
+
this.database.prepare('UPDATE workflow_action_trace SET outcome=?, observed_at=? WHERE attempt_key=? AND outcome=?')
|
|
72
|
+
.run(outcome, new Date().toISOString(), attemptKey, 'Unknown');
|
|
73
|
+
return this.trace(attemptKey);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { completionGateSatisfied } from '../scheduler.js';
|
|
2
|
+
export function verifyAuthoritativeCondition(db, jobId, predicate, taskId, executionId) {
|
|
3
|
+
if (!jobId)
|
|
4
|
+
throw new Error('job ID required');
|
|
5
|
+
const row = db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(jobId);
|
|
6
|
+
if (!row?.json)
|
|
7
|
+
return 'Unknown';
|
|
8
|
+
const job = JSON.parse(row.json);
|
|
9
|
+
if (predicate === 'job.objective-verified')
|
|
10
|
+
return completionGateSatisfied(job) ? 'Verified' : 'Failed';
|
|
11
|
+
if (predicate === 'job.completed')
|
|
12
|
+
return job.state === 'COMPLETED' && completionGateSatisfied(job) ? 'Verified' : 'Failed';
|
|
13
|
+
const task = job.tasks.find(item => item.id === taskId);
|
|
14
|
+
if (!task)
|
|
15
|
+
return 'Unknown';
|
|
16
|
+
switch (predicate) {
|
|
17
|
+
case 'task.queued': return task.state === 'QUEUED' ? 'Verified' : 'Failed';
|
|
18
|
+
case 'task.succeeded': return task.state === 'SUCCEEDED' ? 'Verified' : 'Failed';
|
|
19
|
+
case 'task.failed': return task.state === 'FAILED' ? 'Verified' : 'Failed';
|
|
20
|
+
case 'task.routing-cleared': return task.executionId === null && !task.leaseOwner ? 'Verified' : 'Failed';
|
|
21
|
+
case 'task.execution-stopped': {
|
|
22
|
+
if (!executionId)
|
|
23
|
+
return 'Unknown';
|
|
24
|
+
if (task.executionId === executionId)
|
|
25
|
+
return 'Failed';
|
|
26
|
+
const history = task.executionHistory?.find(entry => entry.id === executionId);
|
|
27
|
+
if (!history)
|
|
28
|
+
return 'Unknown';
|
|
29
|
+
// A changed execution identity cannot alone prove process termination.
|
|
30
|
+
// Require authoritative ended process evidence, not just a lease replacement.
|
|
31
|
+
const table = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='execution_process'").get();
|
|
32
|
+
if (!table)
|
|
33
|
+
return 'Unknown';
|
|
34
|
+
const record = db.prepare('SELECT ended_at FROM execution_process WHERE job_id=? AND task_id=? AND execution_id=?').get(jobId, task.id, executionId);
|
|
35
|
+
return record?.ended_at ? 'Verified' : 'Unknown';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|