agents-relay 2.0.161 → 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 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-clean-history asks Planner to inspect authoritative failed Tasks, decide whether evidence supports superseded/invalid classification or cancellation of a failed review, and apply selected changes through guarded task commands before submitting the Plan. Tasks without sufficient evidence remain unresolved. The durable Plan records the cleanup assessment, and plan apply remains the required receipt. Text responses are never applied as a Plan; unknown execution outcomes are not classified without proof.
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
- console.error(JSON.stringify({ ok: false, code: 'PLAN_APPLY_REJECTED', message: error instanceof Error ? error.message : String(error) }));
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() } : {}), forceCleanHistory: hasArg(args, '--force-clean-history') }), null, 2));
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-clean-history'));
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
+ }
@@ -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, forceCleanHistory = false) {
5
- if (forceCleanHistory && !store.mutateJob)
6
- throw new Error('--force-clean-history requires atomic durable SQLite mutations');
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 (forceCleanHistory && concurrent)
24
- throw new Error(`--force-clean-history requires no pending Plan (${concurrent.id})`);
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 (forceCleanHistory) {
33
- round.forceCleanHistory = true;
34
- round.cleanHistoryTaskIds = current.tasks.filter(task => task.kind !== 'planner' && task.state === 'FAILED' && !task.resolutionClassification).map(task => task.id);
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
  }
@@ -3,15 +3,14 @@ import { attachResolutionOwnership } from './workflow/task-resolution.js';
3
3
  /** Recover persisted child definitions without inventing new task identities. */
4
4
  export async function recoverPlannerChildren(store, job, plannerContext) {
5
5
  for (const plan of (job.plans ?? []).filter(item => item.status === 'SUCCEEDED' && item.plannerResult)) {
6
- const executionContext = await plannerContext(job, plan);
7
- // Historical plans were validated on commit. Rechecking against later Task failures
8
- // blocks recovery before the Troubleshooter graph can run.
9
- const children = plan.plannerResult.next_tasks.map(spec => materializePlannerChild(job, plan, spec, executionContext));
10
- for (const child of children) {
11
- const existing = job.tasks.find(item => item.id === child.id);
12
- if (existing)
6
+ for (const spec of plan.plannerResult.next_tasks) {
7
+ // A succeeded Plan is immutable historical evidence. Existing children may
8
+ // have accumulated execution and Resolution state since the Plan committed;
9
+ // do not rematerialize them against today's Task states.
10
+ if (job.tasks.some(item => item.id === spec.id))
13
11
  continue;
14
- const spec = plan.plannerResult.next_tasks.find(item => item.id === child.id);
12
+ const executionContext = await plannerContext(job, plan);
13
+ const child = materializePlannerChild(job, plan, spec, executionContext);
15
14
  if (spec?.resolution)
16
15
  await store.saveTask(attachResolutionOwnership(job, child, spec.resolution, 'planner'));
17
16
  await store.appendTask(child);
@@ -21,7 +20,6 @@ export async function recoverPlannerChildren(store, job, plannerContext) {
21
20
  const historicalPlanners = job.tasks.filter(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult);
22
21
  if (historicalPlanners.length === 0)
23
22
  return;
24
- const executionContext = await plannerContext(job, historicalPlanners[0]);
25
23
  for (const planner of historicalPlanners) {
26
24
  const result = planner.plannerResult;
27
25
  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 };
@@ -31,10 +29,10 @@ export async function recoverPlannerChildren(store, job, plannerContext) {
31
29
  }
32
30
  else
33
31
  continue;
34
- const children = result.next_tasks.map(spec => materializePlannerChild(job, plan, spec, executionContext));
35
- for (const child of children)
36
- if (!job.tasks.some(item => item.id === child.id)) {
37
- const spec = result.next_tasks.find(item => item.id === child.id);
32
+ for (const spec of result.next_tasks)
33
+ if (!job.tasks.some(item => item.id === spec.id)) {
34
+ const executionContext = await plannerContext(job, planner);
35
+ const child = materializePlannerChild(job, plan, spec, executionContext);
38
36
  if (spec?.resolution)
39
37
  await store.saveTask(attachResolutionOwnership(job, child, spec.resolution, 'planner'));
40
38
  await store.appendTask(child);
@@ -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
- if (live?.executionId === executionId)
69
- clearTimeout(live.timer);
70
- const releaseLive = () => { if (ports.live.get(planId)?.executionId === executionId)
71
- ports.live.delete(planId); };
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
- if (plan.forceCleanHistory) {
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
- validatePlannerResolutionMaterialization(job.tasks, result.next_tasks);
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
- let committedPlan = false;
175
- const committed = await ports.store.mutateJob(job.id, current => {
176
- const durablePlan = (current.plans ?? []).find(item => item.id === plan.id);
177
- if (!durablePlan || durablePlan.status !== 'PLANNING' || durablePlan.executionId !== executionId)
178
- return current;
179
- durablePlan.status = 'SUCCEEDED';
180
- durablePlan.objectiveStatus = result.objective_status;
181
- durablePlan.plannerResult = result;
182
- durablePlan.assessment = result.assessment;
183
- durablePlan.generatedTaskIds = result.next_tasks.map(item => item.id);
184
- durablePlan.pendingUnblocks = result.unblocks.length > 0 ? structuredClone(result.unblocks) : undefined;
185
- durablePlan.error = undefined;
186
- durablePlan.retryFeedback = undefined;
187
- durablePlan.leaseOwner = null;
188
- durablePlan.leaseExpiresAt = null;
189
- durablePlan.executionId = null;
190
- durablePlan.updatedAt = now;
191
- if (current.changeCycle && !current.changeCycle.consumedByPlannerTaskId && durablePlan.trigger.source === 'unblock') {
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
- const consumedUnblocks = new Set(durablePlan.trigger.source === 'unblock' ? durablePlan.trigger.taskIds ?? [] : []);
205
- for (const task of current.tasks) {
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
- else {
229
- plan.status = 'SUCCEEDED';
230
- plan.objectiveStatus = result.objective_status;
231
- plan.plannerResult = result;
232
- plan.assessment = result.assessment;
233
- plan.generatedTaskIds = result.next_tasks.map(item => item.id);
234
- plan.pendingUnblocks = result.unblocks.length > 0 ? structuredClone(result.unblocks) : undefined;
235
- plan.error = undefined;
236
- plan.retryFeedback = undefined;
237
- plan.leaseOwner = null;
238
- plan.leaseExpiresAt = null;
239
- plan.executionId = null;
240
- plan.updatedAt = now;
241
- await ports.store.savePlan(plan);
242
- if (job.changeCycle && !job.changeCycle.consumedByPlannerTaskId && plan.trigger.source === 'unblock') {
243
- job.changeCycle.consumedByPlannerTaskId = plan.id;
244
- job.updatedAt = now;
245
- await ports.store.saveJob(job);
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(plan.trigger.source === 'unblock' ? plan.trigger.taskIds ?? [] : []);
248
- for (const task of job.tasks) {
249
- if (!consumedUnblocks.has(task.id) || !task.unblock || task.state === 'CANCELLED' || plan.generatedTaskIds.includes(task.id))
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 ${plan.id}`;
253
- await ports.store.saveTask(task);
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
- for (const child of children)
257
- if (!job.tasks.some(item => item.id === child.id)) {
258
- const spec = result.next_tasks.find(item => item.id === child.id);
259
- if (spec?.resolution)
260
- await ports.store.saveTask(attachResolutionOwnership(job, child, spec.resolution, 'planner'));
261
- await ports.store.appendTask(child);
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 (cliApply)
302
+ if (caught instanceof PlannerApplyValidationError) {
303
+ if (!(cliApply && caught.retryable))
304
+ releaseLive();
277
305
  throw caught;
278
- await failOrRetry(caught instanceof Error ? caught.message : String(caught));
279
- releaseLive();
280
- return;
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(() => controller.abort(), timeoutMs);
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 }, forceCleanHistory: plan.forceCleanHistory, cleanHistoryTaskIds: plan.cleanHistoryTaskIds, job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: plan.previousPlanId, trigger: plan.trigger, executionContext, retryFeedback: plan.retryFeedback, guidance: plan.guidance };
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
- force_clean_history: context.forceCleanHistory === true,
428
- clean_history_task_ids: context.cleanHistoryTaskIds ?? [],
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,
@@ -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, forceCleanHistory = false) {
277
- return executeManualPlan(this.store, jobId, guidance, this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'), (job, plan) => this.launchPlanner(job, plan), forceCleanHistory);
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
- await finishPlanner(ports, jobId, planId, executionId, null, parsePlannerResult(proposal), true);
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 normalize(json) { const job = normalizeLegacyJobPrNumber(JSON.parse(json)); job.tasks = job.tasks.map(t => normalizeLegacyTaskLifecycle(t, job.executionMode)); return job; }
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 ? normalize(row.json) : null;
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 => normalize(row.json));
24
+ return rows.map(row => normalizeRelayJob(row.json));
11
25
  }
@@ -1,5 +1,5 @@
1
1
  import { normalizeLegacyTaskLifecycle } from './types.js';
2
- import { normalizeLegacyJobPrNumber } from './job-pr-number-migration.js';
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 = normalizeLegacyJobPrNumber(JSON.parse(row.json));
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 = normalizeLegacyJobPrNumber(JSON.parse(row.json));
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);
@@ -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.forceCleanHistory === true);
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.161",
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",
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 failed-history cleanup
102
+ ### Forced Restate audit and validation
103
103
 
104
- When `force_clean_history` is true, inspect every ID in `clean_history_task_ids` against the authoritative Task records and dependency/evidence context before planning. Do not infer a result from text alone. For a failed review that is demonstrably stale, run the existing guarded `agents-relay task cancel` command. For a failed work Task with evidence that replacement work covers it, run the existing guarded `agents-relay task classify --classification superseded`; for proven invalid work, use `--classification invalid`. Supply a different durable `--target-task-id`, and the same concrete `--evidence` and `--reason` in your report. Use the normal repository, PR, and Job options from this invocation. Never change task state directly or claim an action succeeded unless the CLI command succeeds and a fresh `task show` confirms it.
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 and do not classify it, cancel ordinary failed work, or claim success. Do not create related replacement work for an unresolved failure. After all selected guarded commands succeed, refresh authoritative Task state and submit the Plan through the standard `plan apply` command. Include exactly one `history_cleanup` entry for each `clean_history_task_ids` item: `{ "taskId", "outcome": "superseded"|"invalid"|"cancelled_review"|"unresolved", "evidence", "reason", "targetTaskId?" }`. For unresolved items, state what evidence is missing. Relay validates that reported mutations match durable SQLite state; an ordinary text response is never a Plan receipt.
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