agents-relay 2.0.141 → 2.0.143

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ export class ConditionRegistry {
2
+ conditions = new Map();
3
+ constructor(specs) {
4
+ for (const spec of specs) {
5
+ if (this.conditions.has(spec.id))
6
+ throw new Error(`duplicate condition: ${spec.id}`);
7
+ this.conditions.set(spec.id, spec);
8
+ }
9
+ }
10
+ discover() { return [...this.conditions.keys()].sort(); }
11
+ inspect(id) {
12
+ const result = this.conditions.get(id);
13
+ if (!result)
14
+ throw new Error(`unknown condition: ${id}`);
15
+ return result;
16
+ }
17
+ check(id, facts) {
18
+ return this.inspect(id).check(facts);
19
+ }
20
+ guard(id) {
21
+ return ({ durableFacts }) => this.check(id, durableFacts) === 'Verified';
22
+ }
23
+ }
24
+ export function booleanCondition(id) {
25
+ return { id, check: facts => facts[id] === true ? 'Verified' : facts[id] === false ? 'Failed' : 'Unknown' };
26
+ }
27
+ export function verifyActionEffect(registry, actionId, conditionId, facts) {
28
+ return { actionId, conditionId, outcome: registry.check(conditionId, facts) };
29
+ }
30
+ export function safeRetryAllowed(outcome) {
31
+ return outcome === 'Failed';
32
+ }
@@ -1,9 +1,14 @@
1
+ import { ConditionRegistry, booleanCondition } from './conditions.js';
1
2
  import { readFileSync } from 'node:fs';
2
3
  import { GraphRuntime } from './graph-v2.js';
3
4
  import { WORKFLOW_EFFECT_NAMES, WORKFLOW_EVENT_NAMES, WORKFLOW_MUTATION_NAMES, } from './contracts.js';
4
5
  const TASK_STATES = ['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED', 'SUCCEEDED', 'FAILED', 'CANCELLED'];
5
6
  const JOB_STATES = ['DRAFT', 'ACTIVE', 'COMPLETED', 'CANCELLED'];
6
- const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
7
+ const conditionRegistry = new ConditionRegistry([
8
+ ...new Set(['dependenciesSatisfied', 'dependenciesUnsatisfied', 'runnable', 'dispatchable', 'recoverableExecution', 'troubleshooterRecovery', 'retryAvailable', 'autonomousJob', 'platformFallbackSafe', 'notMerged', 'autoMerge', 'prOpen', 'reviewDisabled', 'implementationComplete', 'headAvailable', 'mergeCapable', 'localCompletionAllowed', 'localMergeReady'])
9
+ ].map(booleanCondition));
10
+ const boolGuard = (name) => conditionRegistry.guard(name);
11
+ export const lifecycleConditions = conditionRegistry;
7
12
  function eventRegistry() {
8
13
  return Object.fromEntries(WORKFLOW_EVENT_NAMES.map(id => [id, { id }]));
9
14
  }
@@ -58,16 +58,66 @@ export async function dispatchWorkflowGraph(runtime, options) {
58
58
  let current = options.initialState;
59
59
  const maxSteps = options.maxSteps ?? 1;
60
60
  for (let step = 0; step < maxSteps; step += 1) {
61
+ let deferredTransition = null;
62
+ const deferredEvents = [];
61
63
  const dispatcher = new GraphDispatcher(runtime, {
62
- persistTransition: async (transition) => { await options.persistTransition?.(transition.source, transition.target); },
63
- executeAction: options.executeAction ? async (actionId) => await options.executeAction(actionId) : undefined,
64
- publishEvent: async (event) => { await options.publishEvent?.(event.id, event.payload); },
64
+ persistTransition: async (transition) => {
65
+ if (options.actionVerification)
66
+ deferredTransition = transition;
67
+ else
68
+ await options.persistTransition?.(transition.source, transition.target);
69
+ },
70
+ executeAction: options.executeAction ? async (actionId) => {
71
+ const verification = options.actionVerification;
72
+ if (verification) {
73
+ const identity = verification.identity(actionId);
74
+ const decision = runtime.evaluateSync({ currentState: current, input: options.input, durableFacts: options.durableFacts ?? {} });
75
+ if (!verification.ledger.reserveForRunningTask(identity, decision, verification.conditionId(actionId))) {
76
+ throw new Error('action attempt already reserved: reconcile outcome; never repeat side effect');
77
+ }
78
+ }
79
+ const receipt = await options.executeAction(actionId);
80
+ if (receipt.ok === false)
81
+ throw new Error('action returned failure receipt: reconciliation required');
82
+ if (verification) {
83
+ const identity = verification.identity(actionId);
84
+ const trace = verification.ledger.verify(identity.attemptKey, verification.conditions, await verification.observedFacts(actionId));
85
+ if (trace.outcome !== 'Verified')
86
+ throw new Error('action postcondition ' + trace.outcome + ': reconciliation required');
87
+ }
88
+ return receipt;
89
+ } : undefined,
90
+ publishEvent: async (event) => {
91
+ if (options.actionVerification)
92
+ deferredEvents.push(event);
93
+ else
94
+ await options.publishEvent?.(event.id, event.payload);
95
+ },
65
96
  });
66
97
  const result = await dispatcher.dispatch({
67
98
  currentState: current,
68
99
  input: options.input,
69
100
  durableFacts: options.durableFacts ?? {},
70
101
  });
102
+ if (options.actionVerification && result.decision.accepted) {
103
+ if (result.receipts.some(receipt => receipt.ok === false))
104
+ throw new Error('action failed: reconciliation required; transition withheld');
105
+ const transition = deferredTransition;
106
+ const publication = options.actionVerification.publication;
107
+ const persist = options.actionVerification.persistSqliteTransition;
108
+ if (!publication || !persist || transition === null)
109
+ throw new Error('atomic SQLite transition and outbox required');
110
+ const events = deferredEvents.map((event, index) => {
111
+ const action = result.decision.accepted ? result.decision.actions[0] : undefined;
112
+ if (!action)
113
+ throw new Error('missing durable action event identity');
114
+ const identity = options.actionVerification.identity(action.id);
115
+ return { key: `${identity.attemptKey}/emit/${index}`, eventId: event.id, payload: event.payload };
116
+ });
117
+ publication.commitTransitionAndEvents(() => persist(transition.source, transition.target), events);
118
+ if (options.publishEvent)
119
+ await publication.replay(async (event) => options.publishEvent(event.eventId, event.payload));
120
+ }
71
121
  if (options.observe !== false)
72
122
  observeDecision(options.graphName, options.input, result.decision);
73
123
  if (!result.decision.accepted)
@@ -0,0 +1,85 @@
1
+ export class DurableWorkflowPublication {
2
+ db;
3
+ constructor(db) {
4
+ this.db = db;
5
+ db.exec(`CREATE TABLE IF NOT EXISTS workflow_event_outbox (
6
+ event_key TEXT PRIMARY KEY, event_id TEXT NOT NULL, payload_json TEXT NOT NULL,
7
+ state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('pending','published')),
8
+ created_at TEXT NOT NULL, published_at TEXT
9
+ )`);
10
+ }
11
+ /** State mutation callback MUST use this.db synchronously; rollback covers both writes. */
12
+ commitTransitionAndEvents(transition, events) {
13
+ this.db.exec('BEGIN IMMEDIATE');
14
+ try {
15
+ transition();
16
+ for (const event of (typeof events === 'function' ? events() : events))
17
+ this.enqueue(event.key, event.eventId, event.payload);
18
+ this.db.exec('COMMIT');
19
+ }
20
+ catch (error) {
21
+ this.db.exec('ROLLBACK');
22
+ throw error;
23
+ }
24
+ }
25
+ enqueue(key, eventId, payload) {
26
+ if (!key || !eventId)
27
+ throw new Error('event identity required');
28
+ const encoded = JSON.stringify(payload ?? null);
29
+ this.db.prepare(`INSERT OR IGNORE INTO workflow_event_outbox(event_key,event_id,payload_json,created_at) VALUES(?,?,?,?)`)
30
+ .run(key, eventId, encoded, new Date().toISOString());
31
+ const existing = this.db.prepare('SELECT event_id,payload_json FROM workflow_event_outbox WHERE event_key=?').get(key);
32
+ if (existing.event_id !== eventId || existing.payload_json !== encoded)
33
+ throw new Error('workflow event key collision');
34
+ }
35
+ pending() {
36
+ return this.db.prepare("SELECT event_key,event_id,payload_json FROM workflow_event_outbox WHERE state='pending' ORDER BY created_at,event_key").all().map(row => ({ key: row.event_key, eventId: row.event_id, payload: JSON.parse(row.payload_json) }));
37
+ }
38
+ async replay(publish) {
39
+ let delivered = 0;
40
+ for (const event of this.pending()) {
41
+ await publish(event);
42
+ this.db.prepare("UPDATE workflow_event_outbox SET state='published',published_at=? WHERE event_key=? AND state='pending'").run(new Date().toISOString(), event.key);
43
+ delivered++;
44
+ }
45
+ return delivered;
46
+ }
47
+ }
48
+ /** Atomic SQLite inbox for event consumers. The callback MUST mutate the same DB
49
+ * synchronously; duplicate deliveries return false without repeating the mutation.
50
+ * Consumers with external side effects must use their own idempotency protocol. */
51
+ export class DurableWorkflowConsumer {
52
+ db;
53
+ constructor(db) {
54
+ this.db = db;
55
+ db.exec(`CREATE TABLE IF NOT EXISTS workflow_event_inbox (
56
+ consumer_id TEXT NOT NULL, event_key TEXT NOT NULL, event_id TEXT NOT NULL,
57
+ payload_json TEXT NOT NULL, consumed_at TEXT NOT NULL,
58
+ PRIMARY KEY(consumer_id,event_key)
59
+ )`);
60
+ }
61
+ consume(consumerId, event, apply) {
62
+ if (!consumerId || !event.key || !event.eventId)
63
+ throw new Error('consumer and event identity required');
64
+ const payload = JSON.stringify(event.payload ?? null);
65
+ this.db.exec('BEGIN IMMEDIATE');
66
+ try {
67
+ const previous = this.db.prepare('SELECT event_id,payload_json FROM workflow_event_inbox WHERE consumer_id=? AND event_key=?').get(consumerId, event.key);
68
+ if (previous) {
69
+ if (previous.event_id !== event.eventId || previous.payload_json !== payload)
70
+ throw new Error('consumer event key collision');
71
+ this.db.exec('COMMIT');
72
+ return false;
73
+ }
74
+ apply();
75
+ this.db.prepare('INSERT INTO workflow_event_inbox(consumer_id,event_key,event_id,payload_json,consumed_at) VALUES(?,?,?,?,?)')
76
+ .run(consumerId, event.key, event.eventId, payload, new Date().toISOString());
77
+ this.db.exec('COMMIT');
78
+ return true;
79
+ }
80
+ catch (error) {
81
+ this.db.exec('ROLLBACK');
82
+ throw error;
83
+ }
84
+ }
85
+ }
@@ -26,11 +26,20 @@ export class DurableTaskTransitionRuntime {
26
26
  return null;
27
27
  }
28
28
  const from = task.state;
29
+ const previousUpdatedAt = task.updatedAt;
29
30
  const decision = tryTransitionTaskEvent(task, event, options.facts?.(job, task) ?? {});
30
31
  if (!decision.accepted || !decision.target) {
31
32
  return { job, task, from, decision };
32
33
  }
33
34
  await options.mutate?.(job, task, decision);
35
+ // The local SQLite writer rejects stale snapshots. Wall-clock timestamps may
36
+ // collide or move backwards across rapid start/terminal events; ensure the
37
+ // graph-selected transition has a strictly newer version before saving.
38
+ const previous = Date.parse(previousUpdatedAt);
39
+ const proposed = Date.parse(task.updatedAt);
40
+ if (Number.isFinite(previous) && (!Number.isFinite(proposed) || proposed <= previous)) {
41
+ task.updatedAt = new Date(previous + 1).toISOString();
42
+ }
34
43
  await this.store.saveTask(task);
35
44
  observability().record({
36
45
  stage: 'transition',
@@ -6,13 +6,16 @@ const source = readFileSync(new URL('../../workflow/planner-orchestration.mmd',
6
6
  const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
7
7
  export const plannerOrchestrationV2 = new GraphRuntime(source, {
8
8
  states: new Set([
9
- 'idle', 'check_legacy_pending', 'check_recover', 'check_seed', 'check_planner', 'check_resolution_ownership',
9
+ 'idle', 'check_legacy_pending', 'check_recover', 'check_plan_failure', 'check_plan_recovery', 'check_plan_recovery_exists', 'check_seed', 'check_planner', 'check_resolution_ownership',
10
10
  'check_blocked', 'check_work', 'check_materialized', 'check_objective', 'wait', 'recovered', 'created',
11
11
  ]),
12
12
  guards: {
13
13
  autonomousJob: boolGuard('autonomousJob'),
14
14
  legacyPlanningPending: boolGuard('legacyPlanningPending'),
15
15
  recoverablePendingPlan: boolGuard('recoverablePendingPlan'),
16
+ terminalPlanFailure: boolGuard('terminalPlanFailure'),
17
+ planRecoveryExists: boolGuard('planRecoveryExists'),
18
+ planRecoveryVerified: boolGuard('planRecoveryVerified'),
16
19
  hasSeedWork: boolGuard('hasSeedWork'),
17
20
  noActivePlanner: boolGuard('noActivePlanner'),
18
21
  resolutionOwnedByTroubleshooter: boolGuard('resolutionOwnedByTroubleshooter'),
@@ -22,6 +25,7 @@ export const plannerOrchestrationV2 = new GraphRuntime(source, {
22
25
  objectiveUnsatisfied: boolGuard('objectiveUnsatisfied'),
23
26
  },
24
27
  actions: {
28
+ 'planner.createRecovery': { id: 'planner.createRecovery' },
25
29
  'planner.recoverPending': { id: 'planner.recoverPending' },
26
30
  'planner.createRound': { id: 'planner.createRound' },
27
31
  },
@@ -39,6 +43,12 @@ export function plannerOrchestrationFacts(job, recoverablePendingPlan) {
39
43
  const scopedPlans = (job.plans ?? []).filter(plan => !Number.isFinite(slot) || Date.parse(plan.createdAt) >= slot)
40
44
  .sort((a, b) => a.round - b.round || a.createdAt.localeCompare(b.createdAt));
41
45
  const latestPlan = scopedPlans.at(-1);
46
+ const failedPlan = latestPlan?.status === 'FAILED' ? latestPlan : null;
47
+ const recovery = failedPlan ? job.tasks.find(task => task.agentName === 'troubleshooter' && task.recoveryPlanId === failedPlan.id) : undefined;
48
+ const recoveryVerified = recovery?.state === 'SUCCEEDED'
49
+ && recovery.result?.data?.verified === true
50
+ && typeof recovery.result?.data?.evidence === 'string'
51
+ && recovery.result.data.evidence.trim().length > 0;
42
52
  const legacyLatest = scopedPlans.length === 0
43
53
  ? scopedTasks.filter(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult)
44
54
  .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)).at(-1)
@@ -49,8 +59,11 @@ export function plannerOrchestrationFacts(job, recoverablePendingPlan) {
49
59
  autonomousJob: job.executionMode === 'autonomous',
50
60
  legacyPlanningPending: job.initialPlanning?.status === 'pending',
51
61
  recoverablePendingPlan,
62
+ terminalPlanFailure: Boolean(failedPlan && !failedPlan.manualPlanning && failedPlan.trigger.source !== 'unblock'),
63
+ planRecoveryExists: Boolean(recovery),
64
+ planRecoveryVerified: recoveryVerified,
52
65
  hasSeedWork: !((job.plans?.length ?? 0) === 0 && job.tasks.filter(task => task.kind !== 'planner').length === 0),
53
- noActivePlanner: unblockNeedsNewPlan(job) || !unconsumedUnblockTrigger(job) ? !scopedPlans.some(plan => plan.status === 'PLANNING') : false,
66
+ noActivePlanner: failedPlan?.manualPlanning ? false : unblockNeedsNewPlan(job) || !unconsumedUnblockTrigger(job) ? !scopedPlans.some(plan => plan.status === 'PLANNING') : false,
54
67
  noBlockedWork: Boolean(unconsumedUnblockTrigger(job)) || !scopedTasks.some(task => task.kind !== 'planner' && isEffectivelyBlocked(task)),
55
68
  noActiveImplementation: !scopedTasks.some(task => task.kind !== 'planner' && (['QUEUED', 'RUNNING'].includes(task.state) || (task.state === 'DRAFT' && !task.unblock))),
56
69
  resolutionOwnedByTroubleshooter: scopedTasks.some(task => task.kind !== 'planner' && task.resolution?.owner === 'troubleshooter' && ['active', 'waiting'].includes(task.resolution.state)),
@@ -0,0 +1,35 @@
1
+ /** Derived, read-only reverse usage index. Graph topology remains the only transition authority. */
2
+ import { readFileSync, readdirSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { join, resolve } from 'node:path';
5
+ import { parseGraphV2 } from './graph-v2.js';
6
+ const EXECUTABLE_GRAPH = /^[a-z][a-z0-9-]*\.mmd$/;
7
+ export function executableGraphs(directory) {
8
+ const output = new Map();
9
+ for (const filename of readdirSync(directory).sort()) {
10
+ if (!EXECUTABLE_GRAPH.test(filename) || filename === 'main.mmd')
11
+ continue;
12
+ output.set(filename, parseGraphV2(readFileSync(join(directory, filename), 'utf8')));
13
+ }
14
+ return output;
15
+ }
16
+ export function buildWorkflowUsageIndex(graphs) {
17
+ const usages = [];
18
+ for (const [graphName, graph] of graphs) {
19
+ for (const node of graph.nodes.values()) {
20
+ usages.push({ graph: graphName, kind: node.kind, id: node.semanticId, line: node.line, node: node.id });
21
+ }
22
+ for (const edge of graph.edges) {
23
+ if ((edge.kind === 'on' || edge.kind === 'emit' || edge.kind === 'request') && edge.semanticId) {
24
+ usages.push({ graph: graphName, kind: edge.kind === 'request' ? 'request' : 'event', id: edge.semanticId, line: edge.line });
25
+ }
26
+ }
27
+ }
28
+ return usages.sort((a, b) => a.id.localeCompare(b.id) || a.graph.localeCompare(b.graph) || a.line - b.line);
29
+ }
30
+ export function whereWorkflowSymbol(index, id, kind) {
31
+ return index.filter(item => item.id === id && (!kind || item.kind === kind));
32
+ }
33
+ export function defaultWorkflowDirectory() {
34
+ return resolve(fileURLToPath(new URL('../../workflow/', import.meta.url)));
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.141",
3
+ "version": "2.0.143",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,10 +19,14 @@
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": "node --test test/verification/harness.test.mjs",
23
- "test:verification:l3": "npm run build && node --test test/verification/harness.test.mjs test/verification/l3-sqlite.test.mjs test/verification/l3-ingress.test.mjs test/verification/l3-replay.test.mjs",
24
- "test:verification": "npm run test:verification:l1 && npm run test:verification:l3",
25
- "e2e": "node e2e/runner/run.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",
23
+ "test:verification:l3": "npm run build && node --test 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-planner-round.test.mjs test/verification/full-recovery-gates.test.mjs test/verification/production-wiring.test.mjs",
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
+ "e2e": "node e2e/runner/run.mjs",
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",
27
+ "test:verification:matrix": "node test/verification/recovery-report.mjs /tmp/agents-relay-job481-evidence",
28
+ "test:verification:l2": "npm run build && node --test test/verification/l2/*.test.mjs",
29
+ "test:verification:l2:live": "node test/verification/l2/live-provider.mjs"
26
30
  },
27
31
  "devDependencies": {
28
32
  "@types/node": "^22.10.2",
package/workflow/main.mmd CHANGED
@@ -1,29 +1,56 @@
1
- %% dashboard: presentation-only; not an executable workflow
1
+ %% dashboard: presentation-only — not registered with GraphRuntime or the executable Graph DSL.
2
+ %% Nodes link to the canonical executable workflow graphs. SQLite remains authoritative.
2
3
  flowchart LR
3
- objective["START: Job Objective"] --> planner["Planner"] --> worker["Worker"] --> reviewer["Reviewer"] --> merge["Merge / Delivery"] --> achieved["Objective Achieved"] --> completed["Job Completed"]
4
- reviewer -->|Changes Required| fix["Fix Task"] --> worker
5
- planner -->|Failed| troubleshooter["Troubleshooter"]
6
- worker -->|Failed| troubleshooter
7
- reviewer -->|Failed| troubleshooter
8
- merge -->|Failed| troubleshooter
9
- troubleshooter --> resolution["Resolution"]
10
- resolution -->|Recovered| planner
11
- resolution -->|Recovered| worker
12
- resolution -->|Recovered| reviewer
13
- resolution -->|Recovered| merge
14
- resolution -->|Needs human action| escalator["Escalator workflow"] -->|Resolved| resolution
15
- classDef failure fill:#ffe4e4,stroke:#be3030,color:#7c1818
16
- class troubleshooter,resolution,escalator failure
17
- linkStyle 8,9,10,11,12,13,14,15,16,17,18 stroke:#be3030,stroke-width:2px
4
+ objective["START: Job Objective"]
5
+ planner["Planner"]
6
+ worker["Worker"]
7
+ reviewer["Reviewer"]
8
+ delivery["Merge / Delivery"]
9
+ verify["Objective Verification"]
10
+ completed["Completed"]
11
+ trouble["Troubleshooter"]
12
+ recover{"Recovery conditions verified?"}
13
+ escalation["Escalation: human action only"]
14
+ fix["Changes Required: fix and review again"]
18
15
 
19
- click objective href "job-lifecycle.mmd"
20
- click planner href "planner-orchestration.mmd"
21
- click worker href "task-lifecycle.mmd"
22
- click reviewer href "review-lifecycle.mmd"
23
- click merge href "job-lifecycle.mmd"
24
- click achieved href "job-lifecycle.mmd"
25
- click completed href "job-lifecycle.mmd"
26
- click fix href "review-lifecycle.mmd"
27
- click troubleshooter href "troubleshooter.mmd"
28
- click resolution href "blocker-resolution.mmd"
29
- click escalator href "escalation.mmd"
16
+ objective --> planner
17
+ planner --> worker
18
+ worker --> reviewer
19
+ reviewer -->|approved| delivery
20
+ reviewer -->|changes required| fix
21
+ fix --> worker
22
+ delivery --> verify
23
+ verify -->|verified| completed
24
+
25
+ planner -->|failure| trouble
26
+ worker -->|failure| trouble
27
+ reviewer -->|failure| trouble
28
+ delivery -->|failure| trouble
29
+ verify -->|unknown / failed| trouble
30
+ trouble --> recover
31
+ recover -->|guarded retry to exact failed step| planner
32
+ recover -->|guarded retry to exact failed step| worker
33
+ recover -->|guarded retry to exact failed step| reviewer
34
+ recover -->|guarded retry to exact failed step| delivery
35
+ recover -->|unknown: reconcile first| trouble
36
+ recover -->|human action required| escalation
37
+ escalation -->|resolved: reconcile and resume| trouble
38
+
39
+ click objective href "job-lifecycle.mmd" "Job lifecycle"
40
+ click planner href "planner-orchestration.mmd" "Planner orchestration"
41
+ click worker href "task-lifecycle.mmd" "Task lifecycle"
42
+ click reviewer href "review-lifecycle.mmd" "Review lifecycle"
43
+ click delivery href "job-lifecycle.mmd" "Job completion"
44
+ click verify href "job-lifecycle.mmd" "Objective completion gate"
45
+ click trouble href "troubleshooter.mmd" "Troubleshooter"
46
+ click recover href "task-resolution.mmd" "Task resolution"
47
+ click escalation href "escalation.mmd" "Escalation"
48
+ click fix href "review-lifecycle.mmd" "Review fixes"
49
+
50
+ classDef exception fill:#fee2e2,stroke:#b91c1c,color:#991b1b
51
+ classDef normal fill:#eff6ff,stroke:#2563eb,color:#1e40af
52
+ class trouble,recover,escalation exception
53
+ class objective,planner,worker,reviewer,delivery,verify,completed,fix normal
54
+
55
+ %% Failed/unknown transitions are red; verified retries remain blue.
56
+ linkStyle 8,9,10,11,12,18 stroke:#be3030,stroke-width:2px
@@ -3,6 +3,9 @@ flowchart LR
3
3
  idle["state: idle"]
4
4
  checkLegacy["state: check_legacy_pending"]
5
5
  checkRecover["state: check_recover"]
6
+ checkPlanFailure["state: check_plan_failure"]
7
+ checkPlanRecovery["state: check_plan_recovery"]
8
+ checkPlanRecoveryExists["state: check_plan_recovery_exists"]
6
9
  checkSeed["state: check_seed"]
7
10
  checkPlanner["state: check_planner"]
8
11
  checkResolution["state: check_resolution_ownership"]
@@ -17,6 +20,9 @@ created["state: created"]
17
20
  autonomous["guard: autonomousJob"]
18
21
  legacyPending["guard: legacyPlanningPending"]
19
22
  recoverablePending["guard: recoverablePendingPlan"]
23
+ terminalFailure["guard: terminalPlanFailure"]
24
+ recoveryVerified["guard: planRecoveryVerified"]
25
+ recoveryExists["guard: planRecoveryExists"]
20
26
  hasSeed["guard: hasSeedWork"]
21
27
  noActivePlanner["guard: noActivePlanner"]
22
28
  resolutionOwned["guard: resolutionOwnedByTroubleshooter"]
@@ -26,6 +32,7 @@ materialized["guard: continuationMaterialized"]
26
32
  unsatisfied["guard: objectiveUnsatisfied"]
27
33
 
28
34
  recoverAction["action: planner.recoverPending"]
35
+ createRecovery["action: planner.createRecovery"]
29
36
  createAction["action: planner.createRound"]
30
37
 
31
38
  idle -->|on: planner.reconcile| autonomous
@@ -39,7 +46,20 @@ legacyPending -->|false| checkRecover
39
46
  checkRecover -->|on: planner.reconcile| recoverablePending
40
47
  recoverablePending -->|true| recoverAction
41
48
  recoverAction --> recovered
42
- recoverablePending -->|false| checkSeed
49
+ recoverablePending -->|false| checkPlanFailure
50
+
51
+ checkPlanFailure -->|on: planner.reconcile| terminalFailure
52
+ terminalFailure -->|false| checkSeed
53
+ terminalFailure -->|true| checkPlanRecovery
54
+
55
+ checkPlanRecovery -->|on: planner.reconcile| recoveryVerified
56
+ recoveryVerified -->|true| checkPlanner
57
+ recoveryVerified -->|false| checkPlanRecoveryExists
58
+
59
+ checkPlanRecoveryExists -->|on: planner.reconcile| recoveryExists
60
+ recoveryExists -->|true| wait
61
+ recoveryExists -->|false| createRecovery
62
+ createRecovery --> created
43
63
 
44
64
  checkSeed -->|on: planner.reconcile| hasSeed
45
65
  hasSeed -->|false| wait
@@ -101,6 +101,10 @@ autoMergePending -->|false| approved
101
101
  pending -->|on: review.changes_required| fixPending
102
102
  fixPending --> changes
103
103
 
104
+ %% A failed Reviewer execution may recover with a new Execution ID while
105
+ %% the Review lifecycle remains STARTED. This is a legal re-start event;
106
+ %% Reconciler still fences stale execution generations and immutable head SHA.
107
+ started -->|on: review.started| started
104
108
  started -->|on: review.approved| autoMergeStarted
105
109
  autoMergeStarted -->|true| mergeStarted
106
110
  mergeStarted --> approved