agents-relay 2.0.162 → 2.0.163
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-help.js +1 -1
- package/dist/cli.js +7 -3
- package/dist/force-restate-validation.js +117 -0
- package/dist/force-restate.js +6 -0
- package/dist/manual-plan.js +8 -8
- package/dist/planner-runtime.js +161 -114
- package/dist/planner.js +2 -2
- package/dist/reconciler.js +19 -10
- package/dist/relay-job-snapshot-reads.js +17 -3
- package/dist/relay-workflow-operations.js +3 -3
- package/dist/relayd-control.js +1 -1
- package/dist/store.js +10 -0
- package/dist/workflow/force-restate-validation-v2.js +26 -0
- package/dist/workflow/planner-orchestration-v2.js +2 -2
- package/package.json +3 -3
- package/skills/agents-relay/agents/planner.agent.md +3 -3
- package/workflow/force-restate-validation.mmd +37 -0
package/dist/cli-help.js
CHANGED
|
@@ -173,7 +173,7 @@ Required: --task-id ID and the normal Job storage options.`,
|
|
|
173
173
|
Agent submit: agents-relay plan apply --repo OWNER/REPO --pr PR_NUMBER --id JOB_ID --plan-id PLAN_ID --execution-id EXECUTION_ID --file plan.json. Validation errors reject without partial task writes and can be corrected inside the same Planner execution.
|
|
174
174
|
|
|
175
175
|
Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
|
|
176
|
-
Optional: --prompt TEXT or --prompt-file PATH|- (mutually exclusive). The prompt is additional guidance for this planning round and never replaces the durable Job objective. --force-
|
|
176
|
+
Optional: --prompt TEXT or --prompt-file PATH|- (mutually exclusive). The prompt is additional guidance for this planning round and never replaces the durable Job objective. --force-restate asks Planner to propose evidence-backed dispositions for failed Tasks inside the Plan. Plan apply validates hypothetical history and forward convergence before one atomic SQLite commit; validation errors leave all SQLite state unchanged and can be corrected in the same Planner execution. Unknown execution outcomes remain unresolved without proof. Text responses are never applied as a Plan.
|
|
177
177
|
|
|
178
178
|
The command persists one real plan-N round, materializes validated next_tasks with planner provenance, and wakes normal reconciliation. It works for zero-task DRAFT Jobs. Historical planner-N rounds remain readable, but new rounds use plan-N.
|
|
179
179
|
|
package/dist/cli.js
CHANGED
|
@@ -714,7 +714,11 @@ async function main() {
|
|
|
714
714
|
console.log(JSON.stringify(await reconciler.applyPlannerTree(jobId, planId, executionId, proposal), null, 2));
|
|
715
715
|
}
|
|
716
716
|
catch (error) {
|
|
717
|
-
|
|
717
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'PLAN_APPLY_VALIDATION_FAILED' && 'phase' in error && 'issues' in error) {
|
|
718
|
+
console.error(JSON.stringify({ ok: false, code: error.code, phase: error.phase, retryable: 'retryable' in error ? error.retryable : true, issues: error.issues }));
|
|
719
|
+
}
|
|
720
|
+
else
|
|
721
|
+
console.error(JSON.stringify({ ok: false, code: 'PLAN_APPLY_REJECTED', message: error instanceof Error ? error.message : String(error) }));
|
|
718
722
|
process.exitCode = 1;
|
|
719
723
|
}
|
|
720
724
|
finally {
|
|
@@ -806,7 +810,7 @@ async function main() {
|
|
|
806
810
|
throw new Error(`${command} requires --repo and --id`);
|
|
807
811
|
if (command === 'plan') {
|
|
808
812
|
const guidance = hasArg(args, '--prompt') || hasArg(args, '--prompt-file') ? await textOption(args, '--prompt', '--prompt-file') : undefined;
|
|
809
|
-
console.log(JSON.stringify(await relayControlCommand(args, { command: 'job.plan', jobId, ...(guidance?.trim() ? { guidance: guidance.trim() } : {}),
|
|
813
|
+
console.log(JSON.stringify(await relayControlCommand(args, { command: 'job.plan', jobId, ...(guidance?.trim() ? { guidance: guidance.trim() } : {}), forceRestate: hasArg(args, '--force-restate') }), null, 2));
|
|
810
814
|
return;
|
|
811
815
|
}
|
|
812
816
|
if (command === 'reconcile') {
|
|
@@ -835,7 +839,7 @@ async function main() {
|
|
|
835
839
|
await withEventBus(args, async (bus) => {
|
|
836
840
|
const reconciler = runtime(store, args, bus);
|
|
837
841
|
try {
|
|
838
|
-
const result = await reconciler.planJob(job.id, guidance?.trim() || undefined, hasArg(args, '--force-
|
|
842
|
+
const result = await reconciler.planJob(job.id, guidance?.trim() || undefined, hasArg(args, '--force-restate'));
|
|
839
843
|
await saveLocal(localFile, result.job);
|
|
840
844
|
await publishWake(bus, result.job, result.round.id, `Planning round ${result.round.id} completed`);
|
|
841
845
|
console.log(JSON.stringify(result.round, null, 2));
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { classifyTaskResolution } from './workflow/task-resolution.js';
|
|
2
|
+
import { tryTransitionTaskEvent } from './scheduler.js';
|
|
3
|
+
import { satisfiesWorker } from './worker-routing.js';
|
|
4
|
+
function dependencySatisfied(task, byId) {
|
|
5
|
+
return task.dependencies.every(id => {
|
|
6
|
+
const dependency = byId.get(id);
|
|
7
|
+
return dependency?.state === 'SUCCEEDED'
|
|
8
|
+
|| (dependency?.resolutionClassification?.kind === 'superseded'
|
|
9
|
+
&& byId.get(dependency.resolutionClassification.targetTaskId)?.state === 'SUCCEEDED');
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
function eligibleWorkerRoute(task, candidates) {
|
|
13
|
+
if (!['codex', 'chatgpt'].includes(task.adapter))
|
|
14
|
+
return false;
|
|
15
|
+
const required = task.workerRequirements?.capabilities ?? task.capabilities;
|
|
16
|
+
if (task.workerSelection) {
|
|
17
|
+
if (task.workerSelection.adapter !== task.adapter
|
|
18
|
+
|| required.some(capability => !task.workerSelection.capabilities.includes(capability)))
|
|
19
|
+
return false;
|
|
20
|
+
const selected = candidates?.find(candidate => candidate.adapter === task.adapter && candidate.workerId === task.workerSelection.workerId);
|
|
21
|
+
return Boolean(selected && satisfiesWorker(selected, task.workerRequirements ?? { capabilities: required }, { strictCapabilities: true }));
|
|
22
|
+
}
|
|
23
|
+
return Boolean(candidates?.some(candidate => candidate.adapter === task.adapter
|
|
24
|
+
&& satisfiesWorker(candidate, task.workerRequirements ?? { capabilities: required })));
|
|
25
|
+
}
|
|
26
|
+
function runnable(task, byId, candidates) {
|
|
27
|
+
return ['QUEUED', 'RUNNING'].includes(task.state)
|
|
28
|
+
&& dependencySatisfied(task, byId)
|
|
29
|
+
&& eligibleWorkerRoute(task, candidates)
|
|
30
|
+
&& (task.state !== 'RUNNING' || Boolean(task.executionId && task.leaseOwner));
|
|
31
|
+
}
|
|
32
|
+
function dependencyWaiting(task, byId, candidates, visiting = new Set()) {
|
|
33
|
+
if (task.state !== 'DRAFT' || task.dependencies.length === 0 || visiting.has(task.id))
|
|
34
|
+
return false;
|
|
35
|
+
visiting.add(task.id);
|
|
36
|
+
const accounted = task.dependencies.every(id => {
|
|
37
|
+
const dependency = byId.get(id);
|
|
38
|
+
if (!dependency)
|
|
39
|
+
return false;
|
|
40
|
+
if (dependency.state === 'SUCCEEDED')
|
|
41
|
+
return true;
|
|
42
|
+
if (dependency.resolutionClassification?.kind === 'superseded'
|
|
43
|
+
&& byId.get(dependency.resolutionClassification.targetTaskId)?.state === 'SUCCEEDED')
|
|
44
|
+
return true;
|
|
45
|
+
return runnable(dependency, byId, candidates) || dependencyWaiting(dependency, byId, candidates, visiting);
|
|
46
|
+
});
|
|
47
|
+
visiting.delete(task.id);
|
|
48
|
+
return accounted;
|
|
49
|
+
}
|
|
50
|
+
export function forceRestateRunnable(job, candidates) {
|
|
51
|
+
const byId = new Map(job.tasks.map(task => [task.id, task]));
|
|
52
|
+
return job.tasks.some(task => task.kind !== 'planner' && runnable(task, byId, candidates));
|
|
53
|
+
}
|
|
54
|
+
export function forceRestateDependencyWaiting(job, candidates) {
|
|
55
|
+
const byId = new Map(job.tasks.map(task => [task.id, task]));
|
|
56
|
+
return job.tasks.some(task => task.kind !== 'planner' && dependencyWaiting(task, byId, candidates));
|
|
57
|
+
}
|
|
58
|
+
export function forceRestateBlocked(job, planId) {
|
|
59
|
+
if (!planId)
|
|
60
|
+
return false;
|
|
61
|
+
return job.tasks.some(task => task.agentName === 'troubleshooter' && task.recoveryPlanId === planId && task.state === 'BLOCKED'
|
|
62
|
+
&& typeof task.error === 'string' && task.error.trim().length > 0
|
|
63
|
+
&& typeof task.result?.data?.evidence === 'string' && task.result.data.evidence.trim().length > 0
|
|
64
|
+
&& typeof task.result?.data?.unblock_conditions === 'string' && task.result.data.unblock_conditions.trim().length > 0);
|
|
65
|
+
}
|
|
66
|
+
export function forceRestateConverged(job, planId, candidates) {
|
|
67
|
+
const completed = job.state === 'COMPLETED'
|
|
68
|
+
&& job.tasks.every(task => task.kind === 'planner' || !['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED'].includes(task.state));
|
|
69
|
+
return completed || forceRestateRunnable(job, candidates) || forceRestateDependencyWaiting(job, candidates) || forceRestateBlocked(job, planId);
|
|
70
|
+
}
|
|
71
|
+
/** Apply Planner-proposed history dispositions to a private candidate snapshot.
|
|
72
|
+
* The caller invokes this before the storage mutator returns the candidate. */
|
|
73
|
+
export function applyPlannerHistoryCleanup(job, expectedTaskIds, cleanup, now) {
|
|
74
|
+
const issues = [];
|
|
75
|
+
const expected = new Set(expectedTaskIds);
|
|
76
|
+
if (cleanup.length !== expected.size || cleanup.some(item => !expected.delete(item.taskId)) || expected.size) {
|
|
77
|
+
issues.push(`Provide exactly one history_cleanup disposition for each snapshotted failed Task: ${expectedTaskIds.join(', ') || '(none)'}.`);
|
|
78
|
+
return { valid: false, issues };
|
|
79
|
+
}
|
|
80
|
+
for (const item of cleanup) {
|
|
81
|
+
const task = job.tasks.find(candidate => candidate.id === item.taskId);
|
|
82
|
+
if (!task || task.kind === 'planner' || task.state !== 'FAILED' || task.resolutionClassification) {
|
|
83
|
+
issues.push(`Task ${item.taskId} changed since Force Restate began; reload the Planner context and revise this disposition.`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (task.failureReason === 'worker_outcome_unknown' && item.outcome !== 'unresolved') {
|
|
87
|
+
issues.push(`Task ${item.taskId} has an unknown execution outcome. Keep it unresolved until durable evidence proves the outcome.`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (item.outcome === 'unresolved')
|
|
91
|
+
continue;
|
|
92
|
+
if (item.outcome === 'cancelled_review') {
|
|
93
|
+
if (task.agentName !== 'reviewer' || !tryTransitionTaskEvent(task, 'cancel').accepted) {
|
|
94
|
+
issues.push(`Task ${item.taskId} cannot be cancelled as a failed review. Only a FAILED reviewer Task can use cancelled_review.`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
task.error = `Cancelled by Force Restate: ${item.reason}`;
|
|
98
|
+
task.leaseOwner = null;
|
|
99
|
+
task.leaseExpiresAt = null;
|
|
100
|
+
task.executionId = null;
|
|
101
|
+
task.updatedAt = now;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const target = item.targetTaskId ? job.tasks.find(candidate => candidate.id === item.targetTaskId) : undefined;
|
|
105
|
+
if (!target || target.state !== 'SUCCEEDED') {
|
|
106
|
+
issues.push(`Task ${item.taskId} classification target ${item.targetTaskId ?? '(missing)'} must be an existing SUCCEEDED Task.`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
classifyTaskResolution(task, { kind: item.outcome, targetTaskId: item.targetTaskId, evidence: item.evidence, reason: item.reason }, now);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
issues.push(error instanceof Error ? error.message : String(error));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return issues.length ? { valid: false, issues } : { valid: true };
|
|
117
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { executeManualPlan } from './manual-plan.js';
|
|
2
|
+
/** Force Restate is selected by the planning graph; its history and forward
|
|
3
|
+
* convergence guards run inside the atomic Plan Apply action. */
|
|
4
|
+
export async function executeForceRestate(input) {
|
|
5
|
+
return executeManualPlan(input.store, input.jobId, input.guidance, input.timeoutMs, input.launchPlanner, true);
|
|
6
|
+
}
|
package/dist/manual-plan.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { dispatchJobLifecycleSync } from './workflow/lifecycle.js';
|
|
2
2
|
import { createPlanRound, pendingPlan } from './planner.js';
|
|
3
3
|
import { applyJobActivateAction } from './job-actions.js';
|
|
4
|
-
export async function executeManualPlan(store, jobId, guidance, timeoutMs, launchPlanner,
|
|
5
|
-
if (
|
|
6
|
-
throw new Error('--force-
|
|
4
|
+
export async function executeManualPlan(store, jobId, guidance, timeoutMs, launchPlanner, forceRestate = false) {
|
|
5
|
+
if (forceRestate && !store.mutateJob)
|
|
6
|
+
throw new Error('--force-restate requires atomic durable SQLite mutations');
|
|
7
7
|
let job = await store.load(jobId);
|
|
8
8
|
if (['COMPLETED', 'CANCELLED'].includes(job.state))
|
|
9
9
|
throw new Error(`Cannot plan terminal job ${job.id} (${job.state})`);
|
|
@@ -20,8 +20,8 @@ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launc
|
|
|
20
20
|
if (current.pause)
|
|
21
21
|
throw new Error(`Cannot plan paused Job ${current.id}; resume it first`);
|
|
22
22
|
const concurrent = pendingPlan(current);
|
|
23
|
-
if (
|
|
24
|
-
throw new Error(`--force-
|
|
23
|
+
if (forceRestate && concurrent)
|
|
24
|
+
throw new Error(`--force-restate requires no pending Plan (${concurrent.id})`);
|
|
25
25
|
if (concurrent) {
|
|
26
26
|
if (concurrent.executionId)
|
|
27
27
|
throw new Error(`Planning round ${concurrent.id} is already PLANNING`);
|
|
@@ -29,9 +29,9 @@ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launc
|
|
|
29
29
|
}
|
|
30
30
|
else {
|
|
31
31
|
round = createPlanRound(current, { guidance, manual: true, timeoutMs });
|
|
32
|
-
if (
|
|
33
|
-
round.
|
|
34
|
-
round.
|
|
32
|
+
if (forceRestate) {
|
|
33
|
+
round.forceRestate = true;
|
|
34
|
+
round.restateTaskIds = current.tasks.filter(task => task.kind !== 'planner' && task.state === 'FAILED' && !task.resolutionClassification).map(task => task.id);
|
|
35
35
|
}
|
|
36
36
|
current.plans = [...(current.plans ?? []), structuredClone(round)].sort((a, b) => a.round - b.round);
|
|
37
37
|
}
|
package/dist/planner-runtime.js
CHANGED
|
@@ -9,6 +9,39 @@ import { dispatchJobLifecycleSync } from './workflow/lifecycle.js';
|
|
|
9
9
|
import { withExecutionBoundary } from './reconciler-runtime.js';
|
|
10
10
|
import { storySizeTimeoutMs } from './types.js';
|
|
11
11
|
import { tryTransitionTaskEvent } from './scheduler.js';
|
|
12
|
+
import { applyPlannerHistoryCleanup, forceRestateConverged } from './force-restate-validation.js';
|
|
13
|
+
import { decideForceRestateValidation } from './workflow/force-restate-validation-v2.js';
|
|
14
|
+
import { workerCandidates } from './worker-routing.js';
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
export class PlannerApplyValidationError extends Error {
|
|
17
|
+
phase;
|
|
18
|
+
issues;
|
|
19
|
+
retryable;
|
|
20
|
+
code = 'PLAN_APPLY_VALIDATION_FAILED';
|
|
21
|
+
constructor(phase, issues, retryable = phase !== 'fencing') {
|
|
22
|
+
super(JSON.stringify({ code: 'PLAN_APPLY_VALIDATION_FAILED', phase, retryable, issues }));
|
|
23
|
+
this.phase = phase;
|
|
24
|
+
this.issues = issues;
|
|
25
|
+
this.retryable = retryable;
|
|
26
|
+
this.name = 'PlannerApplyValidationError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function candidateFingerprint(result) {
|
|
30
|
+
return createHash('sha256').update(JSON.stringify(result)).digest('hex');
|
|
31
|
+
}
|
|
32
|
+
function durableJobVersion(job) {
|
|
33
|
+
const normalize = (value) => {
|
|
34
|
+
if (Array.isArray(value))
|
|
35
|
+
return value.map(normalize);
|
|
36
|
+
if (value && typeof value === 'object')
|
|
37
|
+
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, child]) => [key, normalize(child)]));
|
|
38
|
+
return value;
|
|
39
|
+
};
|
|
40
|
+
const snapshot = Object.fromEntries(Object.entries(job).filter(([key]) => key !== 'updatedAt'));
|
|
41
|
+
// updatedAt is a display timestamp and can advance on heartbeat/reconcile
|
|
42
|
+
// writes with no state change. The normalized content hash is the version.
|
|
43
|
+
return createHash('sha256').update(JSON.stringify(normalize(snapshot))).digest('hex');
|
|
44
|
+
}
|
|
12
45
|
/** Activate queued planner work inside the same authoritative SQLite mutation. */
|
|
13
46
|
export function activateCommittedPlannerWork(job) {
|
|
14
47
|
if (job.state !== 'DRAFT' || !job.tasks.some(item => item.state === 'QUEUED'))
|
|
@@ -65,12 +98,24 @@ export async function retryPendingPlannerUnblocks(ports, jobId) {
|
|
|
65
98
|
}
|
|
66
99
|
export async function finishPlanner(ports, jobId, planId, executionId, error, raw, cliApply = false) {
|
|
67
100
|
const live = ports.live.get(planId);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
101
|
+
const releaseLive = () => {
|
|
102
|
+
const current = ports.live.get(planId);
|
|
103
|
+
if (current?.executionId !== executionId)
|
|
104
|
+
return;
|
|
105
|
+
clearTimeout(current.timer);
|
|
106
|
+
ports.live.delete(planId);
|
|
107
|
+
};
|
|
72
108
|
const job = await ports.store.load(jobId);
|
|
73
109
|
const plan = (job.plans ?? []).find(item => item.id === planId);
|
|
110
|
+
if (plan?.status === 'SUCCEEDED' && plan.appliedExecutionId === executionId && raw && !raw.cliApplied) {
|
|
111
|
+
const repeated = parsePlannerResult(raw);
|
|
112
|
+
if (candidateFingerprint(repeated) !== plan.appliedCandidateFingerprint) {
|
|
113
|
+
releaseLive();
|
|
114
|
+
throw new Error(`Plan ${planId} already committed a different candidate for execution ${executionId}`);
|
|
115
|
+
}
|
|
116
|
+
releaseLive();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
74
119
|
if (!plan || (plan.executionId !== executionId && !(raw?.cliApplied && plan.status === 'SUCCEEDED'))) {
|
|
75
120
|
releaseLive();
|
|
76
121
|
if (cliApply)
|
|
@@ -136,29 +181,12 @@ export async function finishPlanner(ports, jobId, planId, executionId, error, ra
|
|
|
136
181
|
let planCommitted = false;
|
|
137
182
|
try {
|
|
138
183
|
const parsed = parsePlannerResult(raw);
|
|
139
|
-
|
|
140
|
-
const cleanup = parsed.history_cleanup ?? [];
|
|
141
|
-
const expected = new Set(plan.cleanHistoryTaskIds ?? []);
|
|
142
|
-
if (cleanup.length !== expected.size || cleanup.some(item => !expected.delete(item.taskId)) || expected.size)
|
|
143
|
-
throw new Error('--force-clean-history requires exactly one Planner disposition for each failed Task in its durable snapshot');
|
|
144
|
-
for (const item of cleanup) {
|
|
145
|
-
const task = job.tasks.find(candidate => candidate.id === item.taskId);
|
|
146
|
-
if (!task)
|
|
147
|
-
throw new Error(`Clean history Task ${item.taskId} disappeared`);
|
|
148
|
-
if (task.failureReason === 'worker_outcome_unknown' && item.outcome !== 'unresolved')
|
|
149
|
-
throw new Error(`Unknown execution outcome for ${item.taskId} must remain unresolved without graph-verified proof`);
|
|
150
|
-
if ((item.outcome === 'superseded' || item.outcome === 'invalid') && (task.resolutionClassification?.kind !== item.outcome || task.resolutionClassification.targetTaskId !== item.targetTaskId || task.resolutionClassification.evidence !== item.evidence || task.resolutionClassification.reason !== item.reason))
|
|
151
|
-
throw new Error(`Clean history classification for ${item.taskId} was not applied through task classify with matching evidence`);
|
|
152
|
-
if (item.outcome === 'cancelled_review' && (task.agentName !== 'reviewer' || task.state !== 'CANCELLED'))
|
|
153
|
-
throw new Error(`Failed review ${item.taskId} must be cancelled through task cancel before Plan apply`);
|
|
154
|
-
if (item.outcome === 'unresolved' && (task.state !== 'FAILED' || task.resolutionClassification))
|
|
155
|
-
throw new Error(`Unresolved history item ${item.taskId} changed after Planner assessment`);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
184
|
+
const historyCleanup = parsed.history_cleanup ?? [];
|
|
158
185
|
const result = assignPlannerTaskIds(parsed, job.repository, job.id, plan.id);
|
|
159
186
|
const executionContext = await ports.plannerContext(job, plan);
|
|
160
187
|
validatePlannerHierarchy(job.tasks, result.next_tasks);
|
|
161
|
-
|
|
188
|
+
if (!plan.forceRestate)
|
|
189
|
+
validatePlannerResolutionMaterialization(job.tasks, result.next_tasks);
|
|
162
190
|
validatePlannerLifecycleOwnership(job, result.next_tasks);
|
|
163
191
|
validatePlannerProgress(job, result);
|
|
164
192
|
if (result.unblocks.length > 0 && !ports.store.registerBlockerDependent)
|
|
@@ -170,97 +198,95 @@ export async function finishPlanner(ports, jobId, planId, executionId, error, ra
|
|
|
170
198
|
throw new Error(`Planner child ${child.id} already exists with a different definition`);
|
|
171
199
|
}
|
|
172
200
|
const now = new Date().toISOString();
|
|
173
|
-
if (ports.store.mutateJob)
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
durablePlan.
|
|
182
|
-
durablePlan.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
current.changeCycle.consumedByPlannerTaskId = durablePlan.id;
|
|
193
|
-
}
|
|
194
|
-
for (const child of children) {
|
|
195
|
-
const spec = result.next_tasks.find(item => item.id === child.id);
|
|
196
|
-
if (spec?.resolution)
|
|
197
|
-
attachResolutionOwnership(current, child, spec.resolution, 'planner');
|
|
198
|
-
const existing = current.tasks.find(item => item.id === child.id);
|
|
199
|
-
if (!existing)
|
|
200
|
-
current.tasks.push(structuredClone(child));
|
|
201
|
-
else if (!samePlannerChildDefinition(existing, child))
|
|
202
|
-
throw new Error(`Planner child ${child.id} already exists with a different definition`);
|
|
201
|
+
if (!ports.store.mutateJob)
|
|
202
|
+
throw new Error('Plan apply requires one atomic durable SQLite mutation');
|
|
203
|
+
const routeCandidates = workerCandidates(ports.options.adapters, await ports.store.listAgents());
|
|
204
|
+
const expectedJobVersion = durableJobVersion(job);
|
|
205
|
+
const committed = await ports.store.mutateJob(job.id, current => {
|
|
206
|
+
const durablePlan = (current.plans ?? []).find(item => item.id === plan.id);
|
|
207
|
+
const changedKeys = Object.keys({ ...job, ...current }).filter(key => key !== 'updatedAt' && JSON.stringify(job[key]) !== JSON.stringify(current[key]));
|
|
208
|
+
if (!durablePlan || durablePlan.status !== 'PLANNING' || durablePlan.executionId !== executionId
|
|
209
|
+
|| durablePlan.updatedAt !== plan.updatedAt || durableJobVersion(current) !== expectedJobVersion) {
|
|
210
|
+
throw new PlannerApplyValidationError('fencing', [`The Job or Planner execution changed while this candidate was being validated (plan=${Boolean(durablePlan)}, status=${durablePlan?.status ?? 'missing'}, execution=${durablePlan?.executionId === executionId}, planVersion=${durablePlan?.updatedAt === plan.updatedAt}, jobVersion=${durableJobVersion(current) === expectedJobVersion}, changed=${changedKeys.join(',')}). Refresh the Planner context and retry with the active execution.`,], durablePlan?.status === 'PLANNING' && durablePlan.executionId === executionId);
|
|
211
|
+
}
|
|
212
|
+
const candidate = structuredClone(current);
|
|
213
|
+
const candidatePlan = (candidate.plans ?? []).find(item => item.id === plan.id);
|
|
214
|
+
const history = candidatePlan.forceRestate
|
|
215
|
+
? applyPlannerHistoryCleanup(candidate, candidatePlan.restateTaskIds ?? [], historyCleanup, now)
|
|
216
|
+
: (historyCleanup.length ? { valid: false, issues: ['history_cleanup is only allowed for a Force Restate Plan. Remove it and submit the Plan again.'] } : { valid: true });
|
|
217
|
+
if (history.valid && candidatePlan.forceRestate) {
|
|
218
|
+
try {
|
|
219
|
+
validatePlannerResolutionMaterialization(candidate.tasks, result.next_tasks);
|
|
203
220
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (!consumedUnblocks.has(task.id) || !task.unblock || task.state === 'CANCELLED' || durablePlan.generatedTaskIds.includes(task.id))
|
|
207
|
-
continue;
|
|
208
|
-
if (tryTransitionTaskEvent(task, 'cancel').accepted) {
|
|
209
|
-
task.error = `Superseded by successful unblock plan ${durablePlan.id}`;
|
|
210
|
-
task.leaseOwner = null;
|
|
211
|
-
task.leaseExpiresAt = null;
|
|
212
|
-
task.executionId = null;
|
|
213
|
-
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
throw new PlannerApplyValidationError('history_consistency', [error instanceof Error ? error.message : String(error)]);
|
|
214
223
|
}
|
|
215
|
-
// Planner children are committed atomically with Job activation.
|
|
216
|
-
// Otherwise the DRAFT Job is invisible to the ACTIVE-only dispatcher.
|
|
217
|
-
activateCommittedPlannerWork(current);
|
|
218
|
-
current.updatedAt = now;
|
|
219
|
-
committedPlan = true;
|
|
220
|
-
return current;
|
|
221
|
-
});
|
|
222
|
-
Object.assign(job, structuredClone(committed));
|
|
223
|
-
if (!committedPlan) {
|
|
224
|
-
releaseLive();
|
|
225
|
-
return;
|
|
226
224
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
225
|
+
candidatePlan.status = 'SUCCEEDED';
|
|
226
|
+
candidatePlan.objectiveStatus = result.objective_status;
|
|
227
|
+
candidatePlan.plannerResult = result;
|
|
228
|
+
candidatePlan.appliedCandidateFingerprint = candidateFingerprint(parsed);
|
|
229
|
+
candidatePlan.assessment = result.assessment;
|
|
230
|
+
candidatePlan.generatedTaskIds = result.next_tasks.map(item => item.id);
|
|
231
|
+
candidatePlan.pendingUnblocks = result.unblocks.length > 0 ? structuredClone(result.unblocks) : undefined;
|
|
232
|
+
candidatePlan.error = undefined;
|
|
233
|
+
candidatePlan.retryFeedback = undefined;
|
|
234
|
+
candidatePlan.leaseOwner = null;
|
|
235
|
+
candidatePlan.leaseExpiresAt = null;
|
|
236
|
+
candidatePlan.executionId = null;
|
|
237
|
+
candidatePlan.appliedExecutionId = executionId ?? undefined;
|
|
238
|
+
candidatePlan.updatedAt = now;
|
|
239
|
+
if (candidate.changeCycle && !candidate.changeCycle.consumedByPlannerTaskId && candidatePlan.trigger.source === 'unblock') {
|
|
240
|
+
candidate.changeCycle.consumedByPlannerTaskId = candidatePlan.id;
|
|
241
|
+
}
|
|
242
|
+
for (const child of children) {
|
|
243
|
+
const spec = result.next_tasks.find(item => item.id === child.id);
|
|
244
|
+
if (spec?.resolution)
|
|
245
|
+
attachResolutionOwnership(candidate, child, spec.resolution, 'planner');
|
|
246
|
+
const existing = candidate.tasks.find(item => item.id === child.id);
|
|
247
|
+
if (!existing)
|
|
248
|
+
candidate.tasks.push(structuredClone(child));
|
|
249
|
+
else if (!samePlannerChildDefinition(existing, child))
|
|
250
|
+
throw new Error(`Planner child ${child.id} already exists with a different definition`);
|
|
246
251
|
}
|
|
247
|
-
const consumedUnblocks = new Set(
|
|
248
|
-
for (const task of
|
|
249
|
-
if (!consumedUnblocks.has(task.id) || !task.unblock || task.state === 'CANCELLED' ||
|
|
252
|
+
const consumedUnblocks = new Set(candidatePlan.trigger.source === 'unblock' ? candidatePlan.trigger.taskIds ?? [] : []);
|
|
253
|
+
for (const task of candidate.tasks) {
|
|
254
|
+
if (!consumedUnblocks.has(task.id) || !task.unblock || task.state === 'CANCELLED' || candidatePlan.generatedTaskIds.includes(task.id))
|
|
250
255
|
continue;
|
|
251
256
|
if (tryTransitionTaskEvent(task, 'cancel').accepted) {
|
|
252
|
-
task.error = `Superseded by successful unblock plan ${
|
|
253
|
-
|
|
257
|
+
task.error = `Superseded by successful unblock plan ${candidatePlan.id}`;
|
|
258
|
+
task.leaseOwner = null;
|
|
259
|
+
task.leaseExpiresAt = null;
|
|
260
|
+
task.executionId = null;
|
|
254
261
|
}
|
|
255
262
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
263
|
+
activateCommittedPlannerWork(candidate);
|
|
264
|
+
candidate.updatedAt = now;
|
|
265
|
+
const forwardValid = forceRestateConverged(candidate, candidatePlan.id, routeCandidates);
|
|
266
|
+
const decision = decideForceRestateValidation({
|
|
267
|
+
forceRestateRequested: candidatePlan.forceRestate === true,
|
|
268
|
+
historyConsistencyValid: history.valid,
|
|
269
|
+
normalHistoryMutationAbsent: !candidatePlan.forceRestate && historyCleanup.length === 0,
|
|
270
|
+
forwardConvergenceValid: forwardValid,
|
|
271
|
+
});
|
|
272
|
+
if (!decision.accepted) {
|
|
273
|
+
throw new PlannerApplyValidationError('forward_convergence', [`Plan Apply graph rejected the candidate: ${decision.reason}.`]);
|
|
274
|
+
}
|
|
275
|
+
if (decision.target === 'rejected') {
|
|
276
|
+
const historyRejected = decision.actions.some(action => action.id === 'plan.apply.rejectHistory');
|
|
277
|
+
const issues = historyRejected && history.valid === false ? history.issues : [
|
|
278
|
+
'The proposed Plan does not converge to executable, dependency-accounted work, an evidence-backed blocked Troubleshooter owned by this Plan, or a completed Job.',
|
|
279
|
+
'Add a queued/running Task with an eligible worker route, make each dependency path lead to eligible work, provide the Troubleshooter evidence and unblock conditions, or preserve verified completion.',
|
|
280
|
+
`Candidate work: ${candidate.tasks.filter(task => task.kind !== 'planner').map(task => `${task.id}:${task.state}/${task.adapter}${task.dependencies.length ? ` deps=${task.dependencies.join(',')}` : ''}`).join('; ') || '(none)'}. Eligible worker adapters: ${[...new Set(routeCandidates.map(item => item.adapter))].join(', ') || '(none)'}.`,
|
|
281
|
+
];
|
|
282
|
+
throw new PlannerApplyValidationError(historyRejected ? 'history_consistency' : 'forward_convergence', issues);
|
|
283
|
+
}
|
|
284
|
+
if (!decision.actions.some(action => action.id === 'plan.apply.commit')) {
|
|
285
|
+
throw new PlannerApplyValidationError('forward_convergence', ['The Plan Apply graph did not select the atomic commit action.']);
|
|
286
|
+
}
|
|
287
|
+
return candidate;
|
|
288
|
+
});
|
|
289
|
+
Object.assign(job, structuredClone(committed));
|
|
264
290
|
planCommitted = true;
|
|
265
291
|
// Cross-Job registrations cannot join this Job's SQLite transaction. The
|
|
266
292
|
// committed Plan keeps replay intent until each external registration is
|
|
@@ -273,17 +299,22 @@ export async function finishPlanner(ports, jobId, planId, executionId, error, ra
|
|
|
273
299
|
releaseLive();
|
|
274
300
|
throw caught;
|
|
275
301
|
}
|
|
276
|
-
if (
|
|
302
|
+
if (caught instanceof PlannerApplyValidationError) {
|
|
303
|
+
if (!(cliApply && caught.retryable))
|
|
304
|
+
releaseLive();
|
|
277
305
|
throw caught;
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
306
|
+
}
|
|
307
|
+
const message = caught instanceof Error ? caught.message : String(caught);
|
|
308
|
+
const validationError = new PlannerApplyValidationError('candidate', [message, 'Correct the candidate and retry plan apply with the same active Planner execution.']);
|
|
309
|
+
if (!(cliApply && validationError.retryable))
|
|
310
|
+
releaseLive();
|
|
311
|
+
throw validationError;
|
|
281
312
|
}
|
|
282
313
|
releaseLive();
|
|
283
314
|
if (!plan.manualPlanning)
|
|
284
315
|
ports.detach(ports.reconcile(jobId), jobId, plan.id, 'planner follow-up reconciliation');
|
|
285
316
|
}
|
|
286
|
-
export async function launchPlanExecution(ports, job, plan) {
|
|
317
|
+
export async function launchPlanExecution(ports, job, plan, waitForCompletion = false) {
|
|
287
318
|
const planner = ports.options.planner;
|
|
288
319
|
if (!planner) {
|
|
289
320
|
let failed = false;
|
|
@@ -348,17 +379,33 @@ export async function launchPlanExecution(ports, job, plan) {
|
|
|
348
379
|
const controller = new AbortController();
|
|
349
380
|
const executionId = plan.executionId;
|
|
350
381
|
const timeoutMs = plan.timeoutMs ?? ports.options.plannerTimeoutMs ?? storySizeTimeoutMs('M');
|
|
351
|
-
const timer = setTimeout(() =>
|
|
382
|
+
const timer = setTimeout(() => {
|
|
383
|
+
controller.abort();
|
|
384
|
+
if (ports.live.get(plan.id)?.executionId === executionId)
|
|
385
|
+
ports.live.delete(plan.id);
|
|
386
|
+
}, timeoutMs);
|
|
352
387
|
ports.live.set(plan.id, { executionId, controller, timer });
|
|
353
388
|
let promise;
|
|
354
389
|
try {
|
|
355
390
|
const executionContext = await ports.plannerContext(job, plan);
|
|
356
|
-
const context = { planExecution: { planId: plan.id, executionId },
|
|
391
|
+
const context = { planExecution: { planId: plan.id, executionId }, forceRestate: plan.forceRestate, restateTaskIds: plan.restateTaskIds, job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: plan.previousPlanId, trigger: plan.trigger, executionContext, retryFeedback: plan.retryFeedback, guidance: plan.guidance };
|
|
357
392
|
promise = planner.plan(context, controller.signal);
|
|
358
393
|
}
|
|
359
394
|
catch (error) {
|
|
360
395
|
await finishPlanner(ports, job.id, plan.id, executionId, error instanceof Error ? error : new Error(String(error)));
|
|
361
396
|
return;
|
|
362
397
|
}
|
|
398
|
+
if (waitForCompletion) {
|
|
399
|
+
let result;
|
|
400
|
+
try {
|
|
401
|
+
result = await promise;
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
await finishPlanner(ports, job.id, plan.id, executionId, error instanceof Error ? error : new Error(String(error)));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
await finishPlanner(ports, job.id, plan.id, executionId, null, result);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
363
410
|
ports.detach(promise.then(result => finishPlanner(ports, job.id, plan.id, executionId, null, result), error => finishPlanner(ports, job.id, plan.id, executionId, error instanceof Error ? error : new Error(String(error)))), job.id, plan.id, 'planner completion');
|
|
364
411
|
}
|
package/dist/planner.js
CHANGED
|
@@ -424,8 +424,8 @@ export function plannerInput(context) {
|
|
|
424
424
|
apply_cli: context.planExecution ? `agents-relay plan apply --repo ${context.job.repository} --pr ${context.job.prNumber} --id ${context.job.id} --plan-id ${context.planExecution.planId} --execution-id ${context.planExecution.executionId} --file PATH` : undefined,
|
|
425
425
|
plan_execution: context.planExecution, retry_feedback: context.retryFeedback,
|
|
426
426
|
caller_prompt: context.guidance,
|
|
427
|
-
|
|
428
|
-
|
|
427
|
+
force_restate: context.forceRestate === true,
|
|
428
|
+
restate_task_ids: context.restateTaskIds ?? [],
|
|
429
429
|
previous_planner_task_id: context.previousPlannerTaskId,
|
|
430
430
|
plan_trigger: context.trigger,
|
|
431
431
|
legacy_change_cycle: context.job.changeCycle,
|
package/dist/reconciler.js
CHANGED
|
@@ -20,6 +20,7 @@ import { retryPendingPlannerUnblocks } from './planner-runtime.js';
|
|
|
20
20
|
import { dispatchPlannerOrchestration, plannerOrchestrationFacts } from './workflow/planner-orchestration-v2.js';
|
|
21
21
|
import { resolveExecutionContext } from './execution-context.js';
|
|
22
22
|
import { ReconcilerReviewBase } from './reconciler-review.js';
|
|
23
|
+
import { executeForceRestate } from './force-restate.js';
|
|
23
24
|
import { recordThreadDelivery, resolveImplementationTaskThreadDestination, prepareReviewerThreadDelivery, resolveReviewerThreadDestination } from './reviewer-thread.js';
|
|
24
25
|
import { executeManualPlan } from './manual-plan.js';
|
|
25
26
|
import { executionEventSource, withExecutionBoundary } from './reconciler-runtime.js';
|
|
@@ -193,7 +194,7 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
193
194
|
await this.ensureFinalReview(scheduled, headSha);
|
|
194
195
|
scheduled = schedule(await this.store.load(jobId));
|
|
195
196
|
}
|
|
196
|
-
if (scheduled.executionMode === 'autonomous') {
|
|
197
|
+
if (scheduled.executionMode === 'autonomous' || (scheduled.plans ?? []).some(plan => plan.forceRestate && plan.status === 'FAILED')) {
|
|
197
198
|
scheduled = schedule(await this.maybeCreatePlanner(scheduled));
|
|
198
199
|
}
|
|
199
200
|
if (scheduled.state !== previousState || scheduled.updatedAt !== job.updatedAt)
|
|
@@ -273,8 +274,12 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
273
274
|
}
|
|
274
275
|
return this.store.load(jobId);
|
|
275
276
|
}
|
|
276
|
-
async planJob(jobId, guidance,
|
|
277
|
-
|
|
277
|
+
async planJob(jobId, guidance, forceRestate = false) {
|
|
278
|
+
if (forceRestate)
|
|
279
|
+
return executeForceRestate({ store: this.store, jobId, guidance,
|
|
280
|
+
timeoutMs: this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'),
|
|
281
|
+
launchPlanner: (job, plan) => this.launchPlanner(job, plan, true) });
|
|
282
|
+
return executeManualPlan(this.store, jobId, guidance, this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'), (job, plan) => this.launchPlanner(job, plan, true));
|
|
278
283
|
}
|
|
279
284
|
async maybeCreatePlanner(job) {
|
|
280
285
|
const pending = pendingPlan(job);
|
|
@@ -430,9 +435,6 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
430
435
|
if (!claimedTask)
|
|
431
436
|
return;
|
|
432
437
|
task = claimedTask;
|
|
433
|
-
// After the durable execution claim, fence this Graph-selected launch
|
|
434
|
-
// before invoking the adapter. An Unknown previous attempt must never be
|
|
435
|
-
// re-launched speculatively. Legacy test stores have no SQLite ledger.
|
|
436
438
|
if (this.store.reserveWorkflowLaunch && task.executionId && task.leaseOwner) {
|
|
437
439
|
const reserved = await this.store.reserveWorkflowLaunch({ jobId: job.id, taskId: task.id, executionId: task.executionId, attempt: task.attempt, leaseOwner: task.leaseOwner });
|
|
438
440
|
if (!reserved)
|
|
@@ -531,22 +533,29 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
531
533
|
: this.runtimeSettled(job.id, task.id, durableExecutionId, null, error instanceof Error ? error.message : String(error))), job.id, task.id, 'worker runtime completion');
|
|
532
534
|
}
|
|
533
535
|
async applyPlannerTree(jobId, planId, executionId, proposal) {
|
|
534
|
-
const { finishPlanner } = await import('./planner-runtime.js');
|
|
536
|
+
const { finishPlanner, PlannerApplyValidationError } = await import('./planner-runtime.js');
|
|
535
537
|
const { parsePlannerResult } = await import('./planner.js');
|
|
536
538
|
const ports = { store: this.store, options: this.options, live: this.plannerLive, plannerContext: (job, plan) => this.plannerContext(job, plan), emit: (event) => this.emit(event), detach: (promise, id, task, label) => this.detach(promise, id, task, label), reconcile: (id) => this.reconcile(id) };
|
|
537
|
-
|
|
539
|
+
let candidate;
|
|
540
|
+
try {
|
|
541
|
+
candidate = parsePlannerResult(proposal);
|
|
542
|
+
}
|
|
543
|
+
catch (error) {
|
|
544
|
+
throw new PlannerApplyValidationError('candidate', [error instanceof Error ? error.message : String(error), 'Correct the candidate and retry plan apply with the same active Planner execution.']);
|
|
545
|
+
}
|
|
546
|
+
await finishPlanner(ports, jobId, planId, executionId, null, candidate, true);
|
|
538
547
|
const updated = (await this.store.load(jobId)).plans?.find(plan => plan.id === planId);
|
|
539
548
|
if (!updated || updated.status !== 'SUCCEEDED')
|
|
540
549
|
throw new Error(`Plan ${planId} was not committed`);
|
|
541
550
|
return updated;
|
|
542
551
|
}
|
|
543
|
-
async launchPlanner(job, plan) {
|
|
552
|
+
async launchPlanner(job, plan, waitForCompletion = false) {
|
|
544
553
|
return launchPlanExecution({
|
|
545
554
|
store: this.store,
|
|
546
555
|
options: this.options,
|
|
547
556
|
live: this.plannerLive,
|
|
548
557
|
plannerContext: (currentJob, currentPlan) => this.plannerContext(currentJob, currentPlan), emit: event => this.emit(event),
|
|
549
558
|
detach: (promise, jobId, planId, context) => this.detach(promise, jobId, planId, context), reconcile: jobId => this.reconcile(jobId),
|
|
550
|
-
}, job, plan);
|
|
559
|
+
}, job, plan, waitForCompletion);
|
|
551
560
|
}
|
|
552
561
|
}
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import { normalizeLegacyTaskLifecycle } from './types.js';
|
|
2
2
|
import { normalizeLegacyJobPrNumber } from './job-pr-number-migration.js';
|
|
3
|
-
function
|
|
3
|
+
export function normalizeRelayJob(json) {
|
|
4
|
+
const job = normalizeLegacyJobPrNumber(JSON.parse(json));
|
|
5
|
+
job.tasks = job.tasks.map(t => normalizeLegacyTaskLifecycle(t, job.executionMode));
|
|
6
|
+
job.plans = (job.plans ?? []).map(plan => {
|
|
7
|
+
const legacy = plan;
|
|
8
|
+
if (plan.forceRestate === undefined && legacy.forceCleanHistory === true)
|
|
9
|
+
plan.forceRestate = true;
|
|
10
|
+
if (plan.restateTaskIds === undefined && legacy.cleanHistoryTaskIds)
|
|
11
|
+
plan.restateTaskIds = [...legacy.cleanHistoryTaskIds];
|
|
12
|
+
delete legacy.forceCleanHistory;
|
|
13
|
+
delete legacy.cleanHistoryTaskIds;
|
|
14
|
+
return plan;
|
|
15
|
+
});
|
|
16
|
+
return job;
|
|
17
|
+
}
|
|
4
18
|
export function loadRelayJob(db, jobId) {
|
|
5
19
|
const row = db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(jobId);
|
|
6
|
-
return row?.json ?
|
|
20
|
+
return row?.json ? normalizeRelayJob(row.json) : null;
|
|
7
21
|
}
|
|
8
22
|
export function listRelayJobs(db, scopeJobId) {
|
|
9
23
|
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 =>
|
|
24
|
+
return rows.map(row => normalizeRelayJob(row.json));
|
|
11
25
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { normalizeLegacyTaskLifecycle } from './types.js';
|
|
2
|
-
import {
|
|
2
|
+
import { normalizeRelayJob } from './relay-job-snapshot-reads.js';
|
|
3
3
|
import { taskLifecycleV2 } from './workflow/definitions-v2.js';
|
|
4
4
|
import { dispatchWorkflowGraphSync } from './workflow/dispatcher.js';
|
|
5
5
|
import { WorkflowActionLedger } from './workflow/action-ledger.js';
|
|
@@ -51,7 +51,7 @@ export class SQLiteWorkflowOperations {
|
|
|
51
51
|
const row = this.db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(jobId);
|
|
52
52
|
if (!row?.json)
|
|
53
53
|
throw new Error(`Unknown Job ${jobId}`);
|
|
54
|
-
const current =
|
|
54
|
+
const current = normalizeRelayJob(row.json);
|
|
55
55
|
current.tasks = current.tasks.map(task => normalizeLegacyTaskLifecycle(task, current.executionMode));
|
|
56
56
|
const previous = structuredClone(current);
|
|
57
57
|
const next = mutation(current);
|
|
@@ -120,7 +120,7 @@ export class SQLiteWorkflowOperations {
|
|
|
120
120
|
const row = this.db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(jobId);
|
|
121
121
|
if (!row?.json)
|
|
122
122
|
throw new Error(`Local authoritative Job ${jobId} not found`);
|
|
123
|
-
const current =
|
|
123
|
+
const current = normalizeRelayJob(row.json);
|
|
124
124
|
current.tasks = current.tasks.map(task => normalizeLegacyTaskLifecycle(task, current.executionMode));
|
|
125
125
|
const previous = structuredClone(current);
|
|
126
126
|
const before = JSON.stringify(current);
|
package/dist/relayd-control.js
CHANGED
|
@@ -109,7 +109,7 @@ export function createWorkspaceControl(deps) {
|
|
|
109
109
|
if (command === 'job.plan') {
|
|
110
110
|
const reconciler = makeReconciler(store, concurrency);
|
|
111
111
|
try {
|
|
112
|
-
const planned = await reconciler.planJob(jobId, typeof body.guidance === 'string' && body.guidance.trim() ? body.guidance : undefined, body.
|
|
112
|
+
const planned = await reconciler.planJob(jobId, typeof body.guidance === 'string' && body.guidance.trim() ? body.guidance : undefined, body.forceRestate === true);
|
|
113
113
|
await pool.reconcileLocalJob(jobId, 'control.job.plan');
|
|
114
114
|
return planned.round;
|
|
115
115
|
}
|
package/dist/store.js
CHANGED
|
@@ -93,6 +93,16 @@ export class InMemoryStore {
|
|
|
93
93
|
}
|
|
94
94
|
async load(jobId) { const job = await this.findJob(jobId); if (!job)
|
|
95
95
|
throw new Error(`Job ${jobId} not found`); job.plans = this.plansForJob(jobId); job.tasks = this.tasksForJob(jobId).map(task => normalizeLegacyTaskLifecycle(task, job.executionMode)); return job; }
|
|
96
|
+
async mutateJob(jobId, mutation) {
|
|
97
|
+
const current = await this.load(jobId);
|
|
98
|
+
const next = mutation(structuredClone(current));
|
|
99
|
+
this.values.set(`${JOB_MARKER}:${jobId}`, renderJobComment(next));
|
|
100
|
+
for (const plan of next.plans ?? [])
|
|
101
|
+
this.values.set(`${PLAN_MARKER}:${jobId}:${plan.id}`, renderPlanComment(plan));
|
|
102
|
+
for (const task of next.tasks)
|
|
103
|
+
this.values.set(`${TASK_MARKER}:${task.jobId ?? jobId}:${task.id}`, renderTaskComment(task));
|
|
104
|
+
return structuredClone(next);
|
|
105
|
+
}
|
|
96
106
|
async saveJob(job) { this.values.set(`${JOB_MARKER}:${job.id}`, renderJobComment(job)); }
|
|
97
107
|
async appendPlan(plan) { const key = `${PLAN_MARKER}:${plan.jobId}:${plan.id}`; const existing = this.values.get(key); if (existing) {
|
|
98
108
|
const current = parsePlan(existing);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { GraphRuntime } from './graph-v2.js';
|
|
3
|
+
import { dispatchWorkflowGraphSync } from './dispatcher.js';
|
|
4
|
+
const source = readFileSync(new URL('../../workflow/force-restate-validation.mmd', import.meta.url), 'utf8');
|
|
5
|
+
export const forceRestateValidationV2 = new GraphRuntime(source, {
|
|
6
|
+
states: new Set(['idle', 'check_history_mode', 'check_history', 'check_normal_history', 'check_forward', 'rejected', 'committed']),
|
|
7
|
+
guards: {
|
|
8
|
+
forceRestateRequested: facts => facts.durableFacts.forceRestateRequested === true,
|
|
9
|
+
historyConsistencyValid: facts => facts.durableFacts.historyConsistencyValid === true,
|
|
10
|
+
normalHistoryMutationAbsent: facts => facts.durableFacts.normalHistoryMutationAbsent === true,
|
|
11
|
+
forwardConvergenceValid: facts => facts.durableFacts.forwardConvergenceValid === true,
|
|
12
|
+
},
|
|
13
|
+
actions: {
|
|
14
|
+
'plan.apply.commit': { id: 'plan.apply.commit' },
|
|
15
|
+
'plan.apply.rejectHistory': { id: 'plan.apply.rejectHistory' },
|
|
16
|
+
'plan.apply.rejectForward': { id: 'plan.apply.rejectForward' },
|
|
17
|
+
},
|
|
18
|
+
events: { 'plan.apply': { id: 'plan.apply' } },
|
|
19
|
+
});
|
|
20
|
+
export function decideForceRestateValidation(facts) {
|
|
21
|
+
return dispatchWorkflowGraphSync(forceRestateValidationV2, {
|
|
22
|
+
initialState: 'idle', input: { transport: 'event', id: 'plan.apply', payload: {} }, durableFacts: facts,
|
|
23
|
+
graphName: 'force-restate-validation',
|
|
24
|
+
isTransientState: state => state.startsWith('check_'), maxSteps: 12,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -56,10 +56,10 @@ export function plannerOrchestrationFacts(job, recoverablePendingPlan) {
|
|
|
56
56
|
const latestId = latestPlan?.id ?? legacyLatest?.id;
|
|
57
57
|
const latestObjective = latestPlan?.objectiveStatus ?? legacyLatest?.plannerResult?.objective_status;
|
|
58
58
|
return {
|
|
59
|
-
autonomousJob: job.executionMode === 'autonomous',
|
|
59
|
+
autonomousJob: job.executionMode === 'autonomous' || latestPlan?.forceRestate === true,
|
|
60
60
|
legacyPlanningPending: job.initialPlanning?.status === 'pending',
|
|
61
61
|
recoverablePendingPlan,
|
|
62
|
-
terminalPlanFailure: Boolean(failedPlan && !failedPlan.manualPlanning && failedPlan.trigger.source !== 'unblock'),
|
|
62
|
+
terminalPlanFailure: Boolean(failedPlan && (!failedPlan.manualPlanning || failedPlan.forceRestate) && failedPlan.trigger.source !== 'unblock'),
|
|
63
63
|
planRecoveryExists: Boolean(recovery),
|
|
64
64
|
planRecoveryVerified: recoveryVerified,
|
|
65
65
|
hasSeedWork: !((job.plans?.length ?? 0) === 0 && job.tasks.filter(task => task.kind !== 'planner').length === 0),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-relay",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.163",
|
|
4
4
|
"description": "Durable async agent jobs coordinated through GitHub pull requests",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
"test:publish": "npm run build && node --import ./test/test-store-env.mjs --test --test-concurrency=1 --test-force-exit test/*.test.js",
|
|
20
20
|
"test:fast": "npm run build && node --import ./test/test-store-env.mjs --test --test-concurrency=2 test/workflow-lifecycle.test.js test/workflow-v2-lifecycle-parity.test.js test/workflow-effects.test.js test/relay-store.test.js test/local-authority.test.js test/local-jobid-routing.test.js test/local-sqlite-jobid.test.js test/no-github.test.js test/scheduled-jobs.test.js test/reconcile-coordinator.test.js test/service-publication.test.js test/github-webhook.test.js",
|
|
21
21
|
"test:full": "npm run build && node --import ./test/test-store-env.mjs --test --test-concurrency=1 test/*.test.js && npm run test:verification:l3",
|
|
22
|
-
"test:verification:l1": "npm run build && node --test test/verification/harness.test.mjs test/verification/contract.test.mjs test/verification/graph-inventory.test.mjs test/verification/dashboard-drilldown.test.mjs test/verification/evidence-matrix.test.mjs test/verification/task-resolution-classification.test.mjs test/verification/planner-contract.test.mjs test/planner-child-recovery.test.js",
|
|
23
|
-
"test:verification:l3": "npm run build && node --test --test-concurrency=1 test/verification/harness.test.mjs test/verification/l3-sqlite.test.mjs test/verification/l3-ingress.test.mjs test/verification/l3-replay.test.mjs test/verification/objective-authority.test.mjs test/verification/l3-convergence.test.mjs test/verification/l3-review-convergence.test.mjs test/verification/l3-human-escalation.test.mjs test/verification/l3-escalation-notifier.test.mjs test/verification/l3-unknown-effect.test.mjs test/verification/l3-cli-actions.test.mjs test/verification/l3-planner-round.test.mjs test/verification/l3-planner-cli.test.mjs test/verification/l3-task-resolution.test.mjs test/verification/full-recovery-gates.test.mjs test/verification/production-wiring.test.mjs test/verification/l3-full-path.test.mjs test/verification/l3-projection-recovery.test.mjs",
|
|
22
|
+
"test:verification:l1": "npm run build && node --test test/verification/harness.test.mjs test/verification/contract.test.mjs test/verification/graph-inventory.test.mjs test/verification/dashboard-drilldown.test.mjs test/verification/evidence-matrix.test.mjs test/verification/task-resolution-classification.test.mjs test/verification/planner-contract.test.mjs test/verification/force-restate-validation.test.mjs test/planner-child-recovery.test.js",
|
|
23
|
+
"test:verification:l3": "npm run build && node --test --test-concurrency=1 test/verification/harness.test.mjs test/verification/l3-sqlite.test.mjs test/verification/l3-ingress.test.mjs test/verification/l3-replay.test.mjs test/verification/objective-authority.test.mjs test/verification/l3-convergence.test.mjs test/verification/l3-review-convergence.test.mjs test/verification/l3-human-escalation.test.mjs test/verification/l3-escalation-notifier.test.mjs test/verification/l3-unknown-effect.test.mjs test/verification/l3-cli-actions.test.mjs test/verification/l3-planner-round.test.mjs test/verification/l3-planner-cli.test.mjs test/verification/l3-task-resolution.test.mjs test/verification/l3-plan-apply-convergence.test.mjs test/verification/full-recovery-gates.test.mjs test/verification/production-wiring.test.mjs test/verification/l3-full-path.test.mjs test/verification/l3-projection-recovery.test.mjs",
|
|
24
24
|
"test:verification": "npm run test:verification:l1 && npm run test:verification:l2 && npm run test:verification:l3 && npm run test:verification:cross-a && node --test test/workflow-production-outbox.test.js && npm run test:verification:matrix",
|
|
25
25
|
"e2e": "node e2e/runner/run.mjs",
|
|
26
26
|
"test:verification:cross-a": "npm run build && RELAY_A_DIST=$PWD/dist node --test test/verification/action-ledger-cross-branch.test.mjs test/verification/outbox-cross-a.test.mjs",
|
|
@@ -99,11 +99,11 @@ Do not create separate Tasks merely for "analyze", "format", "call tool", "check
|
|
|
99
99
|
- **Resolution is mandatory for replacement work:** before returning any `next_tasks`, inspect every existing `FAILED` or `BLOCKED` work Task. If it lacks an active/waiting `resolution` or durable `resolutionClassification` and the proposed batch creates related/sibling work, explicitly cover it with a valid `resolution` on the new recovery Task. A superseded/invalid classification covers only planning validation; preserve the FAILED state and never infer success from the classification. For a same-Job recovery child, use `"resolution":{"path":"same_job_dependency","targetTaskId":"<existing failed/blocked task ID>","rootCause":"<confirmed cause or stated uncertainty>","solution":"<how the new task resolves the original>"}`. `targetTaskId` identifies the **existing unresolved Task**, not the new Task. Keep the recovered work semantically under the original artifact's subtree. Do not invent a resolution or silently cancel unrelated failed work.
|
|
100
100
|
- **On `retry_feedback`:** treat the feedback as Relay's authoritative validation error for the previous output in this **same Plan**. Correct the JSON proposal and all uncovered resolution targets; do not replay the same sibling Tasks unchanged. Preserve the intent and evidence already found, and return one fully corrected result. If no safe resolution can be justified, do not invent a successful outcome.
|
|
101
101
|
|
|
102
|
-
### Forced
|
|
102
|
+
### Forced Restate audit and validation
|
|
103
103
|
|
|
104
|
-
When `
|
|
104
|
+
When `force_restate` is true, use this single forced Planner opportunity to audit the full authoritative semantic Task tree, every historical Task and Resolution classification, all dependency edges, and review target/head evidence. Inspect every ID in `restate_task_ids` against authoritative Task records and evidence. Do not run `task classify` or `task cancel` as a preliminary mutation. Instead include exactly one `history_cleanup` entry for each ID: `{ "taskId", "outcome": "superseded"|"invalid"|"cancelled_review"|"unresolved", "evidence", "reason", "targetTaskId?" }`. `plan apply` validates the proposed dispositions against the current SQLite snapshot and atomically applies them with the Plan and Tasks. A failed validation leaves all SQLite state unchanged, so correct the proposal and retry with the same Plan and execution IDs.
|
|
105
105
|
|
|
106
|
-
If evidence is absent or execution outcome is unknown, leave the Task unresolved
|
|
106
|
+
If evidence is absent or execution outcome is unknown, leave the Task unresolved. A classification target must be an existing SUCCEEDED Task. Do not create replacement work for an unresolved failure. Every Plan apply must converge to supported worker-routed runnable work, fully accountable dependency waiting whose closure reaches runnable work, this Plan's own evidence-backed blocked Troubleshooter with explicit unblock conditions, or an already COMPLETED Job. Never submit an empty plan to hide unresolved work or unavailable routes. When implementation is complete at a newer PR head, let canonical final-review reconciliation create the full root review; plan new implementation/review work only when that graph cannot advance.
|
|
107
107
|
|
|
108
108
|
- Prefer independently verifiable Tasks with stable IDs, but do not split work solely to make Tasks smaller.
|
|
109
109
|
- When a Task depends on mutable repository or product state, distinguish a frozen evidence target from the current execution head. A SHA may freeze that Task's own review/source evidence, but do not copy an upstream SHA into a dependent release, deploy, publication, or other follow-on Task unless the objective explicitly requires that same frozen target. After dependencies complete, downstream Tasks must resolve and verify the current durable Job/PR head before acting.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Plan Apply Convergence Validation
|
|
3
|
+
description: Validates hypothetical Plan application and, for Force Restate, historical consistency before the atomic commit action.
|
|
4
|
+
---
|
|
5
|
+
flowchart LR
|
|
6
|
+
%% relay: initial idle
|
|
7
|
+
idle["state: idle"]
|
|
8
|
+
checkHistoryMode["state: check_history_mode"]
|
|
9
|
+
checkHistory["state: check_history"]
|
|
10
|
+
checkNormalHistory["state: check_normal_history"]
|
|
11
|
+
checkForward["state: check_forward"]
|
|
12
|
+
commit["action: plan.apply.commit"]
|
|
13
|
+
rejected["state: rejected"]
|
|
14
|
+
committed["state: committed"]
|
|
15
|
+
forceRestate["guard: forceRestateRequested"]
|
|
16
|
+
historyConsistent["guard: historyConsistencyValid"]
|
|
17
|
+
forwardConverged["guard: forwardConvergenceValid"]
|
|
18
|
+
normalHistoryClean["guard: normalHistoryMutationAbsent"]
|
|
19
|
+
rejectHistory["action: plan.apply.rejectHistory"]
|
|
20
|
+
rejectForward["action: plan.apply.rejectForward"]
|
|
21
|
+
|
|
22
|
+
idle -->|on: plan.apply| checkHistoryMode
|
|
23
|
+
checkHistoryMode -->|on: plan.apply| forceRestate
|
|
24
|
+
forceRestate -->|true| checkHistory
|
|
25
|
+
forceRestate -->|false| checkNormalHistory
|
|
26
|
+
checkNormalHistory -->|on: plan.apply| normalHistoryClean
|
|
27
|
+
normalHistoryClean -->|true| checkForward
|
|
28
|
+
normalHistoryClean -->|false| rejectHistory
|
|
29
|
+
checkHistory -->|on: plan.apply| historyConsistent
|
|
30
|
+
historyConsistent -->|true| checkForward
|
|
31
|
+
historyConsistent -->|false| rejectHistory
|
|
32
|
+
rejectHistory --> rejected
|
|
33
|
+
checkForward -->|on: plan.apply| forwardConverged
|
|
34
|
+
forwardConverged -->|true| commit
|
|
35
|
+
forwardConverged -->|false| rejectForward
|
|
36
|
+
commit --> committed
|
|
37
|
+
rejectForward --> rejected
|