agents-relay 2.0.37 → 2.0.39

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/README.md CHANGED
@@ -42,6 +42,10 @@ RelayStore owns local operational execution facts; GitHub remains the human-read
42
42
 
43
43
  These paths always represent the current canonical graphs. Historical graph definitions are available through Git history, tags, and commits rather than parallel versioned files in `workflow/`. See [`docs/workflow-engine.md`](docs/workflow-engine.md) for the executable profile and runtime boundary.
44
44
 
45
+ ### Structured runtime observability
46
+
47
+ `src/observability.ts` is the single structured logging/event boundary that records why a lifecycle or projection decision happened. One correlated record chain (`wake -> graph -> transition -> store commit -> projection outcome`) lets an operator reconstruct a representative task lifecycle without ad-hoc stderr parsing. Records are observational only, never a second durable store; they correlate by repository/PR/job/task/execution, classify failures as deterministic `conflict` vs `transient` retry vs `terminal`, redact secrets/prompts, bound large payloads, and keep routine idle/no-op cycles quiet at the default level. See [`docs/structured-observability.md`](docs/structured-observability.md) for the record contract, fields, noise policy, and redaction rules.
48
+
45
49
  ## Quick start
46
50
 
47
51
  For GitHub-backed work, create or adopt the PR through Agents Relay itself. This guarantees the durable job marker exists before any managed task is submitted.
@@ -0,0 +1,186 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ const LEVEL_RANK = { debug: 0, info: 1, warn: 2, error: 3 };
3
+ export const OBSERVABILITY_MAX_STRING = 512;
4
+ export const OBSERVABILITY_MAX_ITEMS = 20;
5
+ export const OBSERVABILITY_MAX_DEPTH = 4;
6
+ export const OBSERVABILITY_REDACTED = '[redacted]';
7
+ const SENSITIVE_KEY = /(secret|token|password|passwd|authorization|cookie|credential|api[_-]?key|private[_-]?key|bearer|prompt)/i;
8
+ const BULK_KEY = /^(payload|input|body|diff|patch|content|messages?)$/i;
9
+ export function errorMessage(error) {
10
+ if (error instanceof Error)
11
+ return error.message;
12
+ if (typeof error === 'string')
13
+ return error;
14
+ if (error && typeof error === 'object') {
15
+ const message = error.message;
16
+ if (typeof message === 'string')
17
+ return message;
18
+ }
19
+ return String(error);
20
+ }
21
+ function failureStatus(error) {
22
+ if (!error || typeof error !== 'object')
23
+ return undefined;
24
+ const value = error;
25
+ for (const candidate of [value.status, value.statusCode, value.response?.status]) {
26
+ if (typeof candidate === 'number' && Number.isFinite(candidate))
27
+ return candidate;
28
+ }
29
+ return undefined;
30
+ }
31
+ function failureRetryAfterMs(error) {
32
+ if (!error || typeof error !== 'object')
33
+ return undefined;
34
+ const value = error;
35
+ const retryAfterSeconds = Number(value.retryAfter);
36
+ if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0)
37
+ return retryAfterSeconds * 1000;
38
+ const retryAfterMs = Number(value.retryAfterMs);
39
+ if (Number.isFinite(retryAfterMs) && retryAfterMs > 0)
40
+ return retryAfterMs;
41
+ for (const headers of [value.response?.headers, value.headers]) {
42
+ const raw = headerValue(headers, 'retry-after');
43
+ const seconds = Number(raw);
44
+ if (Number.isFinite(seconds) && seconds > 0)
45
+ return seconds * 1000;
46
+ }
47
+ return undefined;
48
+ }
49
+ function headerValue(headers, name) {
50
+ if (!headers || typeof headers !== 'object')
51
+ return null;
52
+ if (typeof headers.get === 'function')
53
+ return headers.get(name);
54
+ const record = headers;
55
+ const hit = Object.entries(record).find(([key]) => key.toLowerCase() === name);
56
+ return hit && hit[1] !== undefined ? String(hit[1]) : null;
57
+ }
58
+ /**
59
+ * Deterministic conflict vs transient retry classification.
60
+ *
61
+ * `conflict` means the durable record rejects a divergent definition and retrying
62
+ * will not help. `transient` means the same request may succeed later (rate limit,
63
+ * 5xx, network interruption, retry-after). `terminal` covers everything else.
64
+ */
65
+ export function classifyFailure(error) {
66
+ const reason = errorMessage(error);
67
+ const status = failureStatus(error);
68
+ const retryAfterMs = failureRetryAfterMs(error);
69
+ const classified = { classification: 'terminal', reason };
70
+ if (status !== undefined)
71
+ classified.status = status;
72
+ if (retryAfterMs !== undefined)
73
+ classified.retryAfterMs = retryAfterMs;
74
+ if (/already exists with a different definition/i.test(reason) || status === 409) {
75
+ return { ...classified, classification: 'conflict' };
76
+ }
77
+ if (status === 429
78
+ || (status !== undefined && status >= 500)
79
+ || retryAfterMs !== undefined
80
+ || /\b(ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|ECONNREFUSED|EPIPE|UND_ERR)\b/.test(reason)
81
+ || /fetch failed|network|socket hang up|rate limit|secondary rate|temporarily unavailable/i.test(reason)) {
82
+ return { ...classified, classification: 'transient' };
83
+ }
84
+ return classified;
85
+ }
86
+ /** Bound and redact observation data so records never leak secrets or large payloads. */
87
+ export function sanitizeObservationValue(value, depth = 0) {
88
+ if (value === null || value === undefined)
89
+ return value;
90
+ if (typeof value === 'string') {
91
+ return value.length > OBSERVABILITY_MAX_STRING
92
+ ? `${value.slice(0, OBSERVABILITY_MAX_STRING)}…(+${value.length - OBSERVABILITY_MAX_STRING})`
93
+ : value;
94
+ }
95
+ if (typeof value === 'number' || typeof value === 'boolean')
96
+ return value;
97
+ if (typeof value === 'bigint')
98
+ return value.toString();
99
+ if (typeof value === 'function')
100
+ return '[function]';
101
+ if (Array.isArray(value)) {
102
+ const items = value.slice(0, OBSERVABILITY_MAX_ITEMS).map(item => sanitizeObservationValue(item, depth + 1));
103
+ return value.length > OBSERVABILITY_MAX_ITEMS ? [...items, `+${value.length - OBSERVABILITY_MAX_ITEMS} more`] : items;
104
+ }
105
+ if (typeof value === 'object') {
106
+ if (depth >= OBSERVABILITY_MAX_DEPTH)
107
+ return '[truncated]';
108
+ const out = {};
109
+ for (const [key, item] of Object.entries(value)) {
110
+ if (SENSITIVE_KEY.test(key)) {
111
+ out[key] = OBSERVABILITY_REDACTED;
112
+ continue;
113
+ }
114
+ if (BULK_KEY.test(key) && typeof item === 'string' && item.length > OBSERVABILITY_MAX_STRING) {
115
+ out[key] = `[omitted ${item.length} chars]`;
116
+ continue;
117
+ }
118
+ out[key] = sanitizeObservationValue(item, depth + 1);
119
+ }
120
+ return out;
121
+ }
122
+ return '[unsupported]';
123
+ }
124
+ export function sanitizeObservationData(data) {
125
+ if (!data)
126
+ return undefined;
127
+ return sanitizeObservationValue(data, 0);
128
+ }
129
+ function writeStderrObservation(record) {
130
+ try {
131
+ process.stderr.write(`${JSON.stringify(record)}\n`);
132
+ }
133
+ catch { /* observability must never break execution */ }
134
+ }
135
+ const correlationStorage = new AsyncLocalStorage();
136
+ export function withObservabilityCorrelation(correlation, work) {
137
+ return correlationStorage.run({ ...currentObservabilityCorrelation(), ...correlation }, work);
138
+ }
139
+ export function currentObservabilityCorrelation() {
140
+ return correlationStorage.getStore() ?? {};
141
+ }
142
+ export class RelayObservability {
143
+ sink;
144
+ minimumLevel;
145
+ constructor(sink = writeStderrObservation, minimumLevel = 'info') {
146
+ this.sink = sink;
147
+ this.minimumLevel = minimumLevel;
148
+ }
149
+ /** No-op observer used as the safe default until a runtime configures logging. */
150
+ static silent() { return new RelayObservability(() => undefined, 'error'); }
151
+ static stderr(minimumLevel = 'info') {
152
+ return new RelayObservability(writeStderrObservation, minimumLevel);
153
+ }
154
+ static fromEnvironment(env = process.env) {
155
+ const configured = (env.AGENTS_RELAY_OBSERVABILITY ?? env.RELAY_OBSERVABILITY_LEVEL ?? '').trim();
156
+ if (/^(silent|off|none|0|false)$/i.test(configured))
157
+ return RelayObservability.silent();
158
+ const level = /^(debug|info|warn|error)$/i.test(configured) ? configured.toLowerCase() : 'info';
159
+ return RelayObservability.stderr(level);
160
+ }
161
+ isEnabled(level) { return LEVEL_RANK[level] >= LEVEL_RANK[this.minimumLevel]; }
162
+ record(input) {
163
+ const level = input.level ?? 'info';
164
+ if (!this.isEnabled(level))
165
+ return;
166
+ const observation = {
167
+ ts: new Date().toISOString(),
168
+ stage: input.stage,
169
+ event: input.event,
170
+ level,
171
+ outcome: input.outcome ?? 'succeeded',
172
+ message: input.message,
173
+ correlation: { ...currentObservabilityCorrelation(), ...input.correlation },
174
+ ...(input.transition ? { transition: input.transition } : {}),
175
+ ...(input.data ? { data: sanitizeObservationData(input.data) } : {}),
176
+ };
177
+ this.sink(observation);
178
+ }
179
+ }
180
+ let activeObservability = RelayObservability.silent();
181
+ export function observability() { return activeObservability; }
182
+ export function setObservability(next) { activeObservability = next; }
183
+ /** In-memory sink for tests and diagnostics. */
184
+ export function collectObservations(records) {
185
+ return record => { records.push(record); };
186
+ }
package/dist/pool.js CHANGED
@@ -3,6 +3,7 @@ import { eventFor } from './events.js';
3
3
  import { runScheduledBucket } from './scheduled-jobs.js';
4
4
  import { scheduleBucket } from './scheduler.js';
5
5
  import { ReconcileCoordinator } from './reconcile-coordinator.js';
6
+ import { errorMessage, observability, withObservabilityCorrelation } from './observability.js';
6
7
  const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
7
8
  export function compareJobPriority(a, b) { return RANK[a.priority ?? 'P2'] - RANK[b.priority ?? 'P2'] || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
8
9
  export function runnableManagedJobs(items) { return items.filter(item => item.githubState === 'OPEN' && item.job.state === 'ACTIVE').map(item => item.job).sort(compareJobPriority); }
@@ -47,7 +48,12 @@ export class RepositoryWorkerPool {
47
48
  this.makeStore = makeStore;
48
49
  this.repositories = [...new Set(typeof repository === 'string' ? [repository] : repository)];
49
50
  const reportCoordinatorError = (error, signal) => {
50
- process.stderr.write(`warning: reconcile coordinator failed for ${signal.jobId} (${signal.reason}): ${error instanceof Error ? error.message : String(error)}\n`);
51
+ observability().record({
52
+ stage: 'reconcile', event: 'reconcile.coordinator_failed', level: 'error', outcome: 'failed',
53
+ message: `Reconcile coordinator failed for ${signal.jobId} (${signal.reason}): ${errorMessage(error)}`,
54
+ correlation: { repository: signal.repository, prNumber: signal.prNumber, jobId: signal.jobId },
55
+ data: { reason: signal.reason },
56
+ });
51
57
  };
52
58
  this.coordinator = new ReconcileCoordinator((jobId, signals) => this.consumeSignals(jobId, signals), reportCoordinatorError);
53
59
  this.githubSignalCoordinator = new ReconcileCoordinator((_key, signals) => this.consumeGitHubSignals(signals), reportCoordinatorError);
@@ -103,7 +109,12 @@ export class RepositoryWorkerPool {
103
109
  }
104
110
  const target = this.jobTargets.get(jobId);
105
111
  if (!target || !this.repositories.includes(target.repository)) {
106
- process.stderr.write(`warning: reconcile signal target unknown for job ${jobId}\n`);
112
+ observability().record({
113
+ stage: 'reconcile', event: 'reconcile.signal_unrouted', level: 'warn', outcome: 'skipped',
114
+ message: `Reconcile signal target unknown for job ${jobId}`,
115
+ correlation: { jobId },
116
+ data: { reasons: signals.map(signal => signal.reason) },
117
+ });
107
118
  return;
108
119
  }
109
120
  const seedKey = `${target.repository}#${target.prNumber}:${jobId}`;
@@ -129,11 +140,24 @@ export class RepositoryWorkerPool {
129
140
  const result = await withGitHubRequestContext(this.client, `pool.coordinator-apply:${signal.reason}`, () => reconciler.applyEvent(signal.event));
130
141
  shouldReconcile ||= result.reconcile;
131
142
  }
132
- if (!shouldReconcile)
143
+ if (!shouldReconcile) {
144
+ observability().record({
145
+ stage: 'wake', event: 'wake.signal', level: 'debug', outcome: 'noop',
146
+ message: `Wake for ${target.repository}#${target.prNumber} produced no reconcile work`,
147
+ correlation: { repository: target.repository, prNumber: target.prNumber, jobId },
148
+ data: { reasons: [...new Set(reasons)] },
149
+ });
133
150
  return;
151
+ }
134
152
  const reason = [...new Set(reasons)].join('+') || 'unknown';
153
+ observability().record({
154
+ stage: 'wake', event: 'wake.signal', outcome: 'started',
155
+ message: `Wake for ${target.repository}#${target.prNumber}: ${reason}`,
156
+ correlation: { repository: target.repository, prNumber: target.prNumber, jobId },
157
+ data: { reasons: [...new Set(reasons)] },
158
+ });
135
159
  await this.reconcileExclusive(jobId, async () => {
136
- await withGitHubRequestContext(this.client, `pool.coordinator-reconcile:${reason}`, () => reconciler.reconcile(jobId));
160
+ await withObservabilityCorrelation({ repository: target.repository, prNumber: target.prNumber, jobId }, () => withGitHubRequestContext(this.client, `pool.coordinator-reconcile:${reason}`, () => reconciler.reconcile(jobId)));
137
161
  });
138
162
  }
139
163
  async reconcileDiscovered(discovered) {
@@ -1,6 +1,7 @@
1
1
  import { registerBlockerDependency, resolveBlockerDependencies } from './blockers.js';
2
2
  import { DurableDefinitionConflictError, isDurableDefinitionConflict, sameTaskDefinition } from './store-task-definition.js';
3
3
  import { decideProjectionFailure } from './workflow/projection-conflict-v2.js';
4
+ import { classifyFailure, errorMessage, observability } from './observability.js';
4
5
  function taskKey(task) { return `task:${task.jobId ?? ''}:${task.id}`; }
5
6
  function planKey(plan) { return `plan:${plan.jobId}:${plan.id}`; }
6
7
  function retryHeader(error, name) {
@@ -92,19 +93,36 @@ export class GitHubProjectionWriter {
92
93
  await this.apply(item);
93
94
  await this.relay.completeProjection(item.id);
94
95
  projected += 1;
96
+ observability().record({
97
+ stage: 'projection', event: 'projection.completed', outcome: 'succeeded',
98
+ message: `Projection ${item.operation} applied`,
99
+ data: { operation: item.operation, idempotencyKey: item.idempotencyKey, coalesceKey: item.coalesceKey, attempts: item.attempts },
100
+ });
95
101
  }
96
102
  catch (error) {
97
- const message = error instanceof Error ? error.message : String(error);
103
+ const message = errorMessage(error);
98
104
  const deterministic = isTerminalProjectionFailure(item, error);
105
+ const classified = classifyFailure(error);
99
106
  const decision = decideProjectionFailure({ deterministicDefinitionConflict: deterministic });
100
107
  if (decision.accepted && decision.target === 'conflict') {
101
108
  const diagnostic = conflictDiagnostic(item, error, now.toISOString());
102
109
  await this.relay.recordProjectionConflict(item.id, diagnostic);
103
110
  await this.notifyConflict(diagnostic, message);
111
+ observability().record({
112
+ stage: 'projection', event: 'projection.conflict', level: 'error', outcome: 'failed',
113
+ message: `Projection ${item.operation} has a deterministic conflict: ${message}`,
114
+ data: { operation: item.operation, idempotencyKey: item.idempotencyKey, coalesceKey: item.coalesceKey, attempts: item.attempts, classification: 'conflict', reason: classified.reason },
115
+ });
104
116
  }
105
117
  else {
106
- await this.relay.retryProjection(item.id, message, githubProjectionRetryAt(error, item.attempts, now));
118
+ const retryAt = githubProjectionRetryAt(error, item.attempts, now);
119
+ await this.relay.retryProjection(item.id, message, retryAt);
107
120
  deferred += 1;
121
+ observability().record({
122
+ stage: 'projection', event: 'projection.retry_scheduled', level: 'warn', outcome: 'failed',
123
+ message: `Projection ${item.operation} deferred for retry: ${message}`,
124
+ data: { operation: item.operation, idempotencyKey: item.idempotencyKey, attempts: item.attempts, classification: classified.classification, retryAt: retryAt.toISOString(), status: classified.status, reason: classified.reason },
125
+ });
108
126
  }
109
127
  }
110
128
  }
@@ -195,6 +213,12 @@ export class ProjectedDurableStore {
195
213
  payload: { operation: 'save_job', value },
196
214
  priority: ['COMPLETED', 'CANCELLED'].includes(job.state) ? 100 : 20,
197
215
  });
216
+ observability().record({
217
+ stage: 'store', event: 'store.commit', outcome: 'succeeded',
218
+ message: `Job ${job.id} committed`,
219
+ correlation: { repository: job.repository, prNumber: job.prNumber, jobId: job.id },
220
+ data: { operation: 'save_job', state: job.state, tasks: job.tasks.length },
221
+ });
198
222
  }
199
223
  async appendPlan(plan) {
200
224
  const job = await this.load(plan.jobId);
@@ -246,6 +270,12 @@ export class ProjectedDurableStore {
246
270
  payload: { operation: 'append_task', value: task },
247
271
  priority: 40,
248
272
  });
273
+ observability().record({
274
+ stage: 'store', event: 'store.commit', outcome: 'succeeded',
275
+ message: `Task ${task.id} appended`,
276
+ correlation: { repository: job.repository, prNumber: job.prNumber, jobId: task.jobId, taskId: task.id },
277
+ data: { operation: 'append_task', agentName: task.agentName, storySize: task.storySize },
278
+ });
249
279
  }
250
280
  async saveTask(task) {
251
281
  if (!task.jobId)
@@ -267,6 +297,12 @@ export class ProjectedDurableStore {
267
297
  payload: { operation: 'save_task', value },
268
298
  priority: terminal ? 100 : 50,
269
299
  });
300
+ observability().record({
301
+ stage: 'store', event: 'store.commit', outcome: 'succeeded',
302
+ message: `Task ${task.id} committed (${task.state})`,
303
+ correlation: { repository: job.repository, prNumber: job.prNumber, jobId: task.jobId, taskId: task.id, executionId: task.executionId ?? undefined },
304
+ data: { operation: 'save_task', state: task.state, attempt: task.attempt },
305
+ });
270
306
  }
271
307
  async listAgents() {
272
308
  const local = await this.relay.loadAgents();
@@ -140,11 +140,13 @@ export class ProjectionOutbox {
140
140
  }
141
141
  enqueue(request, now) {
142
142
  const operationKey = logicalOperationKey(request.idempotencyKey);
143
+ let coalesced = false;
143
144
  if (request.coalesceKey) {
144
- this.db.prepare(`
145
+ const removed = this.db.prepare(`
145
146
  DELETE FROM projection_outbox
146
147
  WHERE coalesce_key=? AND status='pending' AND operation_key<>?
147
148
  `).run(request.coalesceKey, operationKey);
149
+ coalesced = Number(removed.changes) > 0;
148
150
  }
149
151
  const existing = this.db.prepare('SELECT status FROM projection_outbox WHERE operation_key=? ORDER BY id DESC LIMIT 1')
150
152
  .get(operationKey);
@@ -171,6 +173,7 @@ export class ProjectionOutbox {
171
173
  last_error=NULL,
172
174
  updated_at=excluded.updated_at
173
175
  `).run(idempotencyKey, operationKey, request.coalesceKey ?? null, request.operation, JSON.stringify(request.payload), request.priority ?? 0, now, now);
176
+ return coalesced;
174
177
  }
175
178
  claimDue(limit, now) {
176
179
  const observedAt = now.toISOString();
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { storySizeTimeoutMs, chatGptStartAckTimeoutMs } from './types.js';
3
- import { dependencyReady, schedule, transitionJobEvent, transitionTaskEvent } from './scheduler.js';
3
+ import { dependencyReady, schedule, transitionTaskEvent } from './scheduler.js';
4
4
  import { applyTaskCancelAction } from './task-actions.js';
5
5
  import { LaunchFailure, StartAckTimeout } from './adapters.js';
6
6
  import { eventFor } from './events.js';
@@ -13,7 +13,6 @@ import { executeManualPlan } from './manual-plan.js';
13
13
  import { executionEventSource, withExecutionBoundary } from './reconciler-runtime.js';
14
14
  export { executionEventSource } from './reconciler-runtime.js';
15
15
  import { CallbackWorkflowEffectHandler } from './workflow/effects.js';
16
- import { applyHistoricalJobCompatibilityRepair } from './workflow/runtime.js';
17
16
  import { pendingTroubleshooterTasks } from './troubleshooter.js';
18
17
  import { prepareManagedExecutionWorktree } from './workspace.js';
19
18
  import { createReviewChairExecutionPlan } from './review-chair.js';
@@ -21,6 +20,7 @@ import { taskTagWorkerPrompt } from './task-tag.js';
21
20
  import { recoverExpiredExecutions, recoverStartupOrphans as recoverStartupExecutionOrphans } from './execution-recovery.js';
22
21
  import { dispatchJobReconcile } from './workflow/job-reconcile-v2.js';
23
22
  import { launchPlanExecution } from './planner-runtime.js';
23
+ import { classifyFailure, errorMessage, observability, withObservabilityCorrelation } from './observability.js';
24
24
  const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
25
25
  export function compareTaskPriority(a, b, jobPriority = 'P2') { const ar = PRIORITY_RANK[a.priority ?? jobPriority]; const br = PRIORITY_RANK[b.priority ?? jobPriority]; return ar - br || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
26
26
  export async function managedWorkerInput(job, task, context) {
@@ -106,6 +106,8 @@ export class Reconciler extends ReconcilerReviewBase {
106
106
  try {
107
107
  const job = await this.store.load(jobId);
108
108
  const previousState = job.state;
109
+ const lifecycleFingerprint = (value) => `${value.state}|${value.tasks.map(task => `${task.id}:${task.state}`).join(',')}`;
110
+ const beforeFingerprint = lifecycleFingerprint(job);
109
111
  const prState = await this.store.pullRequestState?.() ?? 'OPEN';
110
112
  const headSha = prState === 'OPEN' ? await this.currentHeadSha(job) : null;
111
113
  if (headSha && job.headSha !== headSha) {
@@ -116,23 +118,10 @@ export class Reconciler extends ReconcilerReviewBase {
116
118
  await this.invalidateStaleReview(job, headSha);
117
119
  const now = (this.options.now ?? new Date()).getTime();
118
120
  if (prState !== 'OPEN') {
119
- const reason = prState === 'MERGED' ? 'Pull request merged' : 'Pull request closed';
120
- const terminalState = prState === 'MERGED' ? 'COMPLETED' : 'CANCELLED';
121
- if (job.state !== terminalState) {
122
- if (!['DRAFT', 'ACTIVE', 'COMPLETED', 'CANCELLED'].includes(job.state)) {
123
- // Compatibility repair boundary for historical records that persisted runtime status as lifecycle.
124
- applyHistoricalJobCompatibilityRepair(job, terminalState);
125
- await this.store.saveJob(job);
126
- const outcomes = await this.workflowEffects.execute(['effect.cancelActiveTasks'], { jobId: job.id, transitionKey: `compatibility:${prState}`, data: { reason } });
127
- await this.applyJobEffectOutcomes(job, outcomes);
128
- }
129
- else {
130
- await transitionJobEvent(job, prState === 'MERGED' ? 'pr.merged' : 'pr.closed', prState === 'CLOSED' ? { notMerged: true } : {}, async (actionId) => {
131
- const outcomes = await this.workflowEffects.execute([actionId], { jobId: job.id, transitionKey: `terminal:${prState}`, data: { reason } });
132
- await this.applyJobEffectOutcomes(job, outcomes);
133
- }, async () => { await this.store.saveJob(job); });
134
- }
135
- }
121
+ await dispatchJobReconcile(job, { prState, headSha, mergeCapable: Boolean(this.store.mergePullRequest) }, async (actionId) => {
122
+ const outcomes = await this.workflowEffects.execute([actionId], { jobId: job.id, transitionKey: `terminal:${prState}`, data: { reason: `Pull request ${prState.toLowerCase()}` } });
123
+ await this.applyJobEffectOutcomes(job, outcomes);
124
+ }, async () => { await this.store.saveJob(job); });
136
125
  if (prState === 'MERGED' && this.store.resolveBlockerDependents) {
137
126
  const resolved = await this.store.resolveBlockerDependents(job.id, 'merge');
138
127
  for (const target of resolved.unblocked)
@@ -201,7 +190,33 @@ export class Reconciler extends ReconcilerReviewBase {
201
190
  const outcomes = await this.workflowEffects.execute([actionId], { jobId: finalState.id, transitionKey: `reconcile:${headSha ?? 'no-head'}`, data: { headSha } });
202
191
  await this.applyJobEffectOutcomes(finalState, outcomes);
203
192
  }, async () => { await this.store.saveJob(finalState); });
204
- return this.store.load(jobId);
193
+ const settled = await this.store.load(jobId);
194
+ const changed = lifecycleFingerprint(settled) !== beforeFingerprint;
195
+ observability().record({
196
+ stage: 'reconcile',
197
+ event: 'reconcile',
198
+ level: changed ? 'info' : 'debug',
199
+ outcome: changed ? 'succeeded' : 'noop',
200
+ message: changed
201
+ ? `Reconcile updated ${settled.repository}#${settled.prNumber} lifecycle`
202
+ : `Reconcile ${settled.repository}#${settled.prNumber} made no lifecycle change`,
203
+ correlation: { repository: settled.repository, prNumber: settled.prNumber, jobId: settled.id },
204
+ transition: { graph: 'job-lifecycle', from: previousState, to: settled.state },
205
+ data: { prState, tasks: settled.tasks.length },
206
+ });
207
+ return settled;
208
+ }
209
+ catch (error) {
210
+ observability().record({
211
+ stage: 'reconcile',
212
+ event: 'reconcile.failed',
213
+ level: 'error',
214
+ outcome: 'failed',
215
+ message: `Reconcile failed for ${jobId}: ${errorMessage(error)}`,
216
+ correlation: { jobId },
217
+ data: { classification: classifyFailure(error).classification, reason: errorMessage(error) },
218
+ });
219
+ throw error;
205
220
  }
206
221
  finally {
207
222
  this.reconciling = false;
@@ -343,6 +358,9 @@ export class Reconciler extends ReconcilerReviewBase {
343
358
  });
344
359
  }
345
360
  async launch(job, task) {
361
+ return withObservabilityCorrelation({ repository: job.repository, prNumber: job.prNumber, jobId: job.id, taskId: task.id }, () => this.launchCorrelated(job, task));
362
+ }
363
+ async launchCorrelated(job, task) {
346
364
  const adapter = this.options.adapters.find(x => x.name === task.adapter);
347
365
  if (!adapter) {
348
366
  await transitionTaskEvent(task, 'fail');
@@ -436,6 +454,13 @@ export class Reconciler extends ReconcilerReviewBase {
436
454
  void startAckSetup.ack.finally(() => { void startAckSetup.stop(); }).catch(() => { });
437
455
  this.live.set(task.id, { jobId: job.id, taskId: task.id, executionId: durableExecutionId, execution, controller, launchBarrier: Date.now(), workerStarted: false, processSettled: false });
438
456
  this.registerExecutionOwner(job.id, task.id, durableExecutionId);
457
+ observability().record({
458
+ stage: 'transition', event: 'task.dispatch', outcome: 'succeeded',
459
+ message: `Task ${task.id} dispatched to ${task.adapter}`,
460
+ correlation: { repository: job.repository, prNumber: job.prNumber, jobId: job.id, taskId: task.id, executionId: durableExecutionId },
461
+ transition: { graph: 'task-lifecycle', from: 'QUEUED', to: 'QUEUED', actions: ['effect.launchTask'] },
462
+ data: { adapter: adapter.id, attempt: task.attempt, model: task.routing?.model ?? null },
463
+ });
439
464
  const terminalTimer = setTimeout(() => {
440
465
  this.detach(this.timeoutTask(job.id, task.id, durableExecutionId), job.id, task.id, 'worker terminal-event timeout');
441
466
  }, Math.max(1, task.timeoutMs));
@@ -2,6 +2,7 @@ import { DatabaseSync } from 'node:sqlite';
2
2
  import { dirname } from 'node:path';
3
3
  import { mkdirSync } from 'node:fs';
4
4
  import { readdir } from 'node:fs/promises';
5
+ import { observability } from './observability.js';
5
6
  import { ProjectionOutbox, SCHEMA_VERSION, } from './projection-outbox.js';
6
7
  function newerRecord(local, remote) {
7
8
  if (!local)
@@ -117,8 +118,16 @@ export class SQLiteRelayStore {
117
118
  this.db.exec('BEGIN IMMEDIATE');
118
119
  try {
119
120
  this.writeJobSnapshot(job, now);
120
- this.enqueueProjectionInTransaction(request, now);
121
+ const coalesced = this.enqueueProjectionInTransaction(request, now);
121
122
  this.db.exec('COMMIT');
123
+ observability().record({
124
+ stage: 'projection', event: 'projection.enqueued', outcome: coalesced ? 'deduplicated' : 'succeeded',
125
+ message: coalesced
126
+ ? `Projection ${request.operation} coalesced`
127
+ : `Projection ${request.operation} enqueued`,
128
+ correlation: { repository: job.repository, prNumber: job.prNumber, jobId: job.id },
129
+ data: { operation: request.operation, idempotencyKey: request.idempotencyKey, coalesceKey: request.coalesceKey, priority: request.priority ?? 0, coalesced },
130
+ });
122
131
  }
123
132
  catch (error) {
124
133
  this.db.exec('ROLLBACK');
@@ -199,14 +208,21 @@ export class SQLiteRelayStore {
199
208
  return Number(result.changes) === 1;
200
209
  }
201
210
  enqueueProjectionInTransaction(request, now) {
202
- this.outbox.enqueue(request, now);
211
+ return this.outbox.enqueue(request, now);
203
212
  }
204
213
  async enqueueProjection(request) {
205
214
  const now = new Date().toISOString();
206
215
  this.db.exec('BEGIN IMMEDIATE');
207
216
  try {
208
- this.outbox.enqueue(request, now);
217
+ const coalesced = this.enqueueProjectionInTransaction(request, now);
209
218
  this.db.exec('COMMIT');
219
+ observability().record({
220
+ stage: 'projection', event: 'projection.enqueued', outcome: coalesced ? 'deduplicated' : 'succeeded',
221
+ message: coalesced
222
+ ? `Projection ${request.operation} coalesced`
223
+ : `Projection ${request.operation} enqueued`,
224
+ data: { operation: request.operation, idempotencyKey: request.idempotencyKey, coalesceKey: request.coalesceKey, priority: request.priority ?? 0, coalesced },
225
+ });
210
226
  }
211
227
  catch (error) {
212
228
  this.db.exec('ROLLBACK');
package/dist/relayd.js CHANGED
@@ -27,6 +27,13 @@ import { summarizeRuntime } from './runtime-summary.js';
27
27
  import { executionRouterRulesPath } from './routing-policy.js';
28
28
  import { workerCandidates } from './worker-routing.js';
29
29
  import { routeExecution } from './execution-routing.js';
30
+ import { RelayObservability, classifyFailure, errorMessage, observability, setObservability } from './observability.js';
31
+ /** Initialize the process-wide structured observability sink once, from environment. */
32
+ export function configureDaemonObservability(env = process.env) {
33
+ const configured = RelayObservability.fromEnvironment(env);
34
+ setObservability(configured);
35
+ return configured;
36
+ }
30
37
  export async function overlayOperationalOverviews(overviews, storeFor) {
31
38
  return await Promise.all(overviews.map(async (item) => {
32
39
  const store = storeFor(item.job.repository, item.job.prNumber);
@@ -128,6 +135,7 @@ export async function runDaemon(argv) {
128
135
  console.log(daemonHelp);
129
136
  return;
130
137
  }
138
+ configureDaemonObservability();
131
139
  configureExecutionRouterRulesPath(argv);
132
140
  await ensureChatGptBrowserWorkspace();
133
141
  const normalized = normalizeDaemonArguments(argv);
@@ -198,7 +206,7 @@ export async function runDaemon(argv) {
198
206
  return cachedOverviews;
199
207
  }
200
208
  catch (error) {
201
- process.stderr.write(`warning: workspace job refresh unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
209
+ observability().record({ stage: 'daemon', event: 'daemon.refresh_failed', level: 'warn', outcome: 'failed', message: `Workspace job refresh unavailable: ${errorMessage(error)}`, data: { classification: classifyFailure(error).classification, reason: errorMessage(error) } });
202
210
  return cachedOverviews;
203
211
  }
204
212
  };
@@ -364,7 +372,7 @@ export async function runDaemon(argv) {
364
372
  return { ...refreshed.job, githubState: refreshed.githubState, runtimeStatus: deriveJobRuntimeStatus(refreshed.job, refreshed.githubState), mergedAt: refreshed.mergedAt, closedAt: refreshed.closedAt, draft: refreshed.draft, publication: refreshed.publication };
365
373
  }
366
374
  catch (error) {
367
- process.stderr.write(`warning: selected job refresh unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
375
+ observability().record({ stage: 'daemon', event: 'daemon.refresh_failed', level: 'warn', outcome: 'failed', message: `Selected job refresh unavailable: ${errorMessage(error)}`, data: { classification: classifyFailure(error).classification, reason: errorMessage(error) } });
368
376
  return cachedJob();
369
377
  }
370
378
  };
@@ -448,7 +456,7 @@ export async function runDaemon(argv) {
448
456
  await pool.reconcile();
449
457
  }
450
458
  catch (error) {
451
- process.stderr.write(`warning: workspace reconcile unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
459
+ observability().record({ stage: 'daemon', event: 'daemon.reconcile_failed', level: 'warn', outcome: 'failed', message: `Workspace reconcile unavailable: ${errorMessage(error)}`, data: { classification: classifyFailure(error).classification, reason: errorMessage(error) } });
452
460
  }
453
461
  };
454
462
  const scheduleHeartbeatSafely = async () => {
@@ -458,7 +466,7 @@ export async function runDaemon(argv) {
458
466
  await pool.scheduleTickSignal(bus, item.job, now);
459
467
  }
460
468
  catch (error) {
461
- process.stderr.write(`warning: scheduled heartbeat unavailable for ${item.job.repository}#${item.job.prNumber}: ${error instanceof Error ? error.message : String(error)}\n`);
469
+ observability().record({ stage: 'daemon', event: 'daemon.schedule_heartbeat_failed', level: 'warn', outcome: 'failed', message: `Scheduled heartbeat unavailable: ${errorMessage(error)}`, correlation: { repository: item.job.repository, prNumber: item.job.prNumber, jobId: item.job.id }, data: { classification: classifyFailure(error).classification, reason: errorMessage(error) } });
462
470
  }
463
471
  }
464
472
  };
@@ -468,7 +476,7 @@ export async function runDaemon(argv) {
468
476
  await writer.flushOnce();
469
477
  }
470
478
  catch (error) {
471
- process.stderr.write(`warning: GitHub projection flush unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
479
+ observability().record({ stage: 'daemon', event: 'daemon.projection_flush_failed', level: 'warn', outcome: 'failed', message: `GitHub projection flush unavailable: ${errorMessage(error)}`, data: { classification: classifyFailure(error).classification, reason: errorMessage(error) } });
472
480
  }
473
481
  }
474
482
  };
@@ -495,7 +503,7 @@ export async function runDaemon(argv) {
495
503
  console.log('Dashboard listening; repository worker pool active');
496
504
  void refreshAll(true)
497
505
  .then(items => pool.reconcileKnown(items))
498
- .catch(error => process.stderr.write('warning: startup remote refresh unavailable: ' + (error instanceof Error ? error.message : String(error)) + '\n'));
506
+ .catch(error => observability().record({ stage: 'daemon', event: 'daemon.startup_refresh_failed', level: 'warn', outcome: 'failed', message: 'Startup remote refresh unavailable: ' + errorMessage(error), data: { classification: classifyFailure(error).classification, reason: errorMessage(error) } }));
499
507
  }
500
508
  if (isEntrypoint(import.meta.url))
501
509
  runDaemon(process.argv.slice(2)).catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
package/dist/scheduler.js CHANGED
@@ -3,6 +3,7 @@ import { dispatchJobLifecycle, dispatchTaskLifecycle, dispatchTaskLifecycleSync
3
3
  import { reconcileTaskDependencies } from './workflow/task-reconcile.js';
4
4
  export { dependencyReady, detectCycles } from './workflow/task-reconcile.js';
5
5
  import { dispatchScheduleCycle } from './workflow/schedule-cycle-v2.js';
6
+ import { observability } from './observability.js';
6
7
  export function completionGateSatisfied(job) {
7
8
  if (job.schedule)
8
9
  return false;
@@ -52,14 +53,31 @@ export async function transitionTaskEvent(task, event, context = {}, executeEffe
52
53
  throw new Error(decision.reason === 'ambiguous'
53
54
  ? `Ambiguous workflow transition from ${from} on ${event}`
54
55
  : `No enabled workflow transition from ${from} on ${event}`);
56
+ observability().record({
57
+ stage: 'transition',
58
+ event,
59
+ outcome: 'succeeded',
60
+ message: `Task ${task.id} ${from} -> ${decision.target}`,
61
+ correlation: { jobId: task.jobId, taskId: task.id, executionId: task.executionId ?? undefined },
62
+ transition: { graph: 'task-lifecycle', from, to: decision.target, actions: decision.actions },
63
+ });
55
64
  return decision;
56
65
  }
57
66
  export async function transitionJobEvent(job, event, context = {}, executeEffect, persist) {
67
+ const from = job.state;
58
68
  const decision = await dispatchJobLifecycle(job, event, context, executeEffect, persist);
59
69
  if (!decision.accepted || !decision.target)
60
70
  throw new Error(decision.reason === 'ambiguous'
61
- ? `Ambiguous workflow transition from ${job.state} on ${event}`
62
- : `No enabled workflow transition from ${job.state} on ${event}`);
71
+ ? `Ambiguous workflow transition from ${from} on ${event}`
72
+ : `No enabled workflow transition from ${from} on ${event}`);
73
+ observability().record({
74
+ stage: 'transition',
75
+ event,
76
+ outcome: 'succeeded',
77
+ message: `Job ${job.id} ${from} -> ${decision.target}`,
78
+ correlation: { repository: job.repository, prNumber: job.prNumber, jobId: job.id },
79
+ transition: { graph: 'job-lifecycle', from, to: decision.target, actions: decision.actions },
80
+ });
63
81
  return decision;
64
82
  }
65
83
  export const SCHEDULE_RESOLUTION_MS = 600_000;
@@ -13,8 +13,8 @@ export const authoritativeUnblockV2 = new GraphRuntime(source, {
13
13
  events: { 'task.authoritative_unblock': { id: 'task.authoritative_unblock' } },
14
14
  });
15
15
  export async function dispatchAuthoritativeUnblock(autonomousJob, executeAction) {
16
- return dispatchWorkflowGraph(authoritativeUnblockV2, { initialState: 'blocked', input: { transport: 'event', id: 'task.authoritative_unblock', payload: {} }, durableFacts: { autonomousJob }, executeAction });
16
+ return dispatchWorkflowGraph(authoritativeUnblockV2, { initialState: 'blocked', input: { transport: 'event', id: 'task.authoritative_unblock', payload: {} }, graphName: 'authoritative-unblock', durableFacts: { autonomousJob }, executeAction });
17
17
  }
18
18
  export function decideAuthoritativeUnblock(autonomousJob) {
19
- return dispatchWorkflowGraphSync(authoritativeUnblockV2, { initialState: 'blocked', input: { transport: 'event', id: 'task.authoritative_unblock', payload: {} }, durableFacts: { autonomousJob } });
19
+ return dispatchWorkflowGraphSync(authoritativeUnblockV2, { initialState: 'blocked', input: { transport: 'event', id: 'task.authoritative_unblock', payload: {} }, graphName: 'authoritative-unblock', durableFacts: { autonomousJob } });
20
20
  }
@@ -26,8 +26,8 @@ export const blockerResolutionV2 = new GraphRuntime(source, {
26
26
  events: { 'blocker.resolved': { id: 'blocker.resolved' } },
27
27
  });
28
28
  export async function dispatchBlockerResolution(facts, executeAction) {
29
- return dispatchWorkflowGraph(blockerResolutionV2, { initialState: 'waiting', input: { transport: 'event', id: 'blocker.resolved', payload: {} }, durableFacts: facts, isTransientState: state => state.startsWith('check_'), maxSteps: 10, executeAction });
29
+ return dispatchWorkflowGraph(blockerResolutionV2, { initialState: 'waiting', input: { transport: 'event', id: 'blocker.resolved', payload: {} }, durableFacts: facts, graphName: 'blocker-resolution', isTransientState: state => state.startsWith('check_'), maxSteps: 10, executeAction });
30
30
  }
31
31
  export function decideBlockerResolution(facts) {
32
- return dispatchWorkflowGraphSync(blockerResolutionV2, { initialState: 'waiting', input: { transport: 'event', id: 'blocker.resolved', payload: {} }, durableFacts: facts, isTransientState: state => state.startsWith('check_'), maxSteps: 10 });
32
+ return dispatchWorkflowGraphSync(blockerResolutionV2, { initialState: 'waiting', input: { transport: 'event', id: 'blocker.resolved', payload: {} }, durableFacts: facts, graphName: 'blocker-resolution', isTransientState: state => state.startsWith('check_'), maxSteps: 10 });
33
33
  }
@@ -1,4 +1,54 @@
1
1
  import { GraphDispatcher } from './graph-v2.js';
2
+ import { observability } from '../observability.js';
3
+ /**
4
+ * Record graph/decision identity and selected transition/action.
5
+ *
6
+ * `no_transition` is emitted at debug so normal idle watchdog cycles stay quiet;
7
+ * accepted transitions and ambiguous paths are visible at info/warn.
8
+ */
9
+ function observeDecision(graphName, input, decision) {
10
+ if (decision.accepted) {
11
+ const noOp = decision.source === decision.target && decision.actions.length === 0;
12
+ observability().record({
13
+ stage: 'graph',
14
+ event: input.id,
15
+ level: noOp ? 'debug' : undefined,
16
+ outcome: noOp ? 'noop' : 'succeeded',
17
+ message: noOp
18
+ ? `Graph ${graphName ?? 'workflow'} accepted no-op ${decision.source} -> ${decision.target}`
19
+ : `Graph ${graphName ?? 'workflow'} accepted ${decision.source} -> ${decision.target}`,
20
+ transition: {
21
+ graph: graphName ?? null,
22
+ from: decision.source,
23
+ to: decision.target,
24
+ actions: decision.actions.map(action => action.id),
25
+ },
26
+ data: { transport: input.transport },
27
+ });
28
+ return;
29
+ }
30
+ if (decision.reason === 'ambiguous') {
31
+ observability().record({
32
+ stage: 'graph',
33
+ event: input.id,
34
+ level: 'warn',
35
+ outcome: 'failed',
36
+ message: `Graph ${graphName ?? 'workflow'} found ambiguous path from ${decision.source}`,
37
+ transition: { graph: graphName ?? null, from: decision.source, to: null },
38
+ data: { transport: input.transport, reason: decision.reason },
39
+ });
40
+ return;
41
+ }
42
+ observability().record({
43
+ stage: 'graph',
44
+ event: input.id,
45
+ level: 'debug',
46
+ outcome: 'noop',
47
+ message: `Graph ${graphName ?? 'workflow'} has no transition from ${decision.source} on ${input.id}`,
48
+ transition: { graph: graphName ?? null, from: decision.source, to: null },
49
+ data: { transport: input.transport, reason: decision.reason },
50
+ });
51
+ }
2
52
  /**
3
53
  * The single production graph execution boundary.
4
54
  * Ingress adapters normalize input/facts, while GraphDispatcher alone evaluates,
@@ -18,6 +68,8 @@ export async function dispatchWorkflowGraph(runtime, options) {
18
68
  input: options.input,
19
69
  durableFacts: options.durableFacts ?? {},
20
70
  });
71
+ if (options.observe !== false)
72
+ observeDecision(options.graphName, options.input, result.decision);
21
73
  if (!result.decision.accepted)
22
74
  return result.decision;
23
75
  current = result.decision.target;
@@ -39,6 +91,8 @@ export function dispatchWorkflowGraphSync(runtime, options) {
39
91
  throw new Error('sync workflow publishEvent returned Promise'); },
40
92
  });
41
93
  const result = dispatcher.dispatchSync({ currentState: current, input: options.input, durableFacts: options.durableFacts ?? {} });
94
+ if (options.observe !== false)
95
+ observeDecision(options.graphName, options.input, result.decision);
42
96
  if (!result.decision.accepted)
43
97
  return result.decision;
44
98
  current = result.decision.target;
@@ -52,5 +106,12 @@ export async function dispatchWorkflowTargetOverride(runtime, options) {
52
106
  // It intentionally executes no workflow actions/effects: this is a state repair, not an event replay.
53
107
  const { assertV2StateTransition } = await import('./definitions-v2.js');
54
108
  assertV2StateTransition(runtime.graph, options.currentState, options.targetState, options.kind);
109
+ observability().record({
110
+ stage: 'graph',
111
+ event: 'target.override',
112
+ outcome: 'succeeded',
113
+ message: `Administrative ${options.kind} state repair ${options.currentState} -> ${options.targetState}`,
114
+ transition: { graph: `${options.kind}.override`, from: options.currentState, to: options.targetState, actions: [] },
115
+ });
55
116
  await options.persistTransition(options.currentState, options.targetState);
56
117
  }
@@ -1,4 +1,5 @@
1
1
  import { tryTransitionTaskEvent } from '../scheduler.js';
2
+ import { observability } from '../observability.js';
2
3
  /**
3
4
  * Runtime primitive for applying one canonical Task-graph event to fresh durable state.
4
5
  *
@@ -15,14 +16,30 @@ export class DurableTaskTransitionRuntime {
15
16
  return this.withLock(`${jobId}/${taskId}`, async () => {
16
17
  const job = await this.store.load(jobId);
17
18
  const task = job.tasks.find(item => item.id === taskId);
18
- if (!task || (options.eligible && !options.eligible(job, task)))
19
+ if (!task || (options.eligible && !options.eligible(job, task))) {
20
+ observability().record({
21
+ stage: 'transition', event, level: 'debug', outcome: 'skipped',
22
+ message: `Task ${taskId} transition ${event} not eligible`,
23
+ correlation: { repository: job.repository, prNumber: job.prNumber, jobId, taskId },
24
+ transition: { graph: 'task-lifecycle', from: task?.state ?? null, to: null },
25
+ });
19
26
  return null;
27
+ }
20
28
  const from = task.state;
21
29
  const decision = tryTransitionTaskEvent(task, event, options.facts?.(job, task) ?? {});
22
- if (!decision.accepted || !decision.target)
30
+ if (!decision.accepted || !decision.target) {
23
31
  return { job, task, from, decision };
32
+ }
24
33
  await options.mutate?.(job, task, decision);
25
34
  await this.store.saveTask(task);
35
+ observability().record({
36
+ stage: 'transition',
37
+ event,
38
+ outcome: 'succeeded',
39
+ message: `Task ${taskId} ${from} -> ${decision.target}`,
40
+ correlation: { repository: job.repository, prNumber: job.prNumber, jobId, taskId, executionId: task.executionId ?? undefined },
41
+ transition: { graph: 'task-lifecycle', from, to: decision.target, actions: decision.actions },
42
+ });
26
43
  return { job, task, from, decision };
27
44
  });
28
45
  }
@@ -1,3 +1,4 @@
1
+ import { classifyFailure, observability } from '../observability.js';
1
2
  export class MemoryWorkflowEffectReceiptStore {
2
3
  completed = new Set();
3
4
  async has(key) { return this.completed.has(key); }
@@ -48,6 +49,7 @@ export class WorkflowEffectExecutor {
48
49
  if (await this.receipts.has(key)) {
49
50
  const outcome = { effect, key, status: 'deduplicated' };
50
51
  outcomes.push(outcome);
52
+ this.recordEffect(context, outcome);
51
53
  await onOutcome?.(outcome);
52
54
  continue;
53
55
  }
@@ -56,6 +58,7 @@ export class WorkflowEffectExecutor {
56
58
  await this.receipts.markCompleted(key);
57
59
  const outcome = { effect, key, status: 'completed' };
58
60
  outcomes.push(outcome);
61
+ this.recordEffect(context, outcome);
59
62
  await onOutcome?.(outcome);
60
63
  }
61
64
  catch (error) {
@@ -66,9 +69,29 @@ export class WorkflowEffectExecutor {
66
69
  error: error instanceof Error ? error.message : String(error),
67
70
  };
68
71
  outcomes.push(outcome);
72
+ this.recordEffect(context, outcome, error);
69
73
  await onOutcome?.(outcome);
70
74
  }
71
75
  }
72
76
  return outcomes;
73
77
  }
78
+ recordEffect(context, outcome, error) {
79
+ observability().record({
80
+ stage: 'transition',
81
+ event: outcome.status === 'failed' ? 'workflow.effect.failed' : 'workflow.effect',
82
+ level: outcome.status === 'failed' ? 'error' : 'info',
83
+ outcome: outcome.status === 'deduplicated' ? 'deduplicated' : outcome.status === 'failed' ? 'failed' : 'succeeded',
84
+ message: outcome.status === 'failed'
85
+ ? `Effect ${outcome.effect} failed: ${outcome.error ?? 'unknown error'}`
86
+ : `Effect ${outcome.effect} ${outcome.status}`,
87
+ correlation: { jobId: context.jobId, taskId: context.taskId },
88
+ transition: { graph: 'workflow.effect', from: null, to: null, actions: [outcome.effect] },
89
+ data: {
90
+ effect: outcome.effect,
91
+ idempotencyKey: outcome.key,
92
+ transitionKey: context.transitionKey,
93
+ ...(error ? { classification: classifyFailure(error).classification } : {}),
94
+ },
95
+ });
96
+ }
74
97
  }
@@ -1,19 +1,65 @@
1
1
  import { decideJobLifecycle, dispatchJobLifecycle } from './lifecycle.js';
2
2
  import { implementationComplete } from '../runtime-status.js';
3
- export function decideJobReconcile(job, facts) {
4
- return decideJobLifecycle(job, 'reconcile', {
5
- prOpen: facts.prState === 'OPEN',
3
+ import { applyHistoricalJobCompatibilityRepair } from './runtime.js';
4
+ function lifecycleInput(facts) {
5
+ if (facts.prState === 'MERGED')
6
+ return { event: 'pr.merged', context: {} };
7
+ if (facts.prState === 'CLOSED')
8
+ return { event: 'pr.closed', context: { notMerged: true } };
9
+ return {
10
+ event: 'reconcile',
11
+ context: {
12
+ prOpen: true,
13
+ headAvailable: Boolean(facts.headSha),
14
+ mergeCapable: facts.mergeCapable,
15
+ },
16
+ };
17
+ }
18
+ function graphFacts(job, facts) {
19
+ const input = lifecycleInput(facts);
20
+ if (input.event !== 'reconcile')
21
+ return input.context;
22
+ return {
23
+ ...input.context,
6
24
  reviewDisabled: job.reviewEnabled === false,
7
25
  implementationComplete: implementationComplete(job),
8
26
  autoMerge: job.autoMerge !== false,
9
- headAvailable: Boolean(facts.headSha),
10
- mergeCapable: facts.mergeCapable,
11
- });
27
+ };
28
+ }
29
+ /**
30
+ * Historical compatibility only. Normal Job lifecycle is always decided by
31
+ * workflow/job-lifecycle.mmd from the PR fact-derived event.
32
+ */
33
+ function repairHistoricalTerminalState(job, facts) {
34
+ if (['DRAFT', 'ACTIVE', 'COMPLETED', 'CANCELLED'].includes(job.state))
35
+ return null;
36
+ if (facts.prState === 'MERGED')
37
+ applyHistoricalJobCompatibilityRepair(job, 'COMPLETED');
38
+ else if (facts.prState === 'CLOSED')
39
+ applyHistoricalJobCompatibilityRepair(job, 'CANCELLED');
40
+ else
41
+ return null;
42
+ return job.state;
43
+ }
44
+ export function decideJobReconcile(job, facts) {
45
+ const input = lifecycleInput(facts);
46
+ return decideJobLifecycle(job, input.event, graphFacts(job, facts));
12
47
  }
13
48
  export async function dispatchJobReconcile(job, facts, executeEffect, persist) {
14
- return dispatchJobLifecycle(job, 'reconcile', {
15
- prOpen: facts.prState === 'OPEN', reviewDisabled: job.reviewEnabled === false,
16
- implementationComplete: implementationComplete(job), autoMerge: job.autoMerge !== false,
17
- headAvailable: Boolean(facts.headSha), mergeCapable: facts.mergeCapable,
18
- }, executeEffect, persist);
49
+ const repaired = repairHistoricalTerminalState(job, facts);
50
+ if (repaired) {
51
+ await persist?.();
52
+ await executeEffect?.('effect.cancelActiveTasks');
53
+ return {
54
+ accepted: true,
55
+ target: repaired,
56
+ guard: null,
57
+ actions: ['effect.cancelActiveTasks'],
58
+ mutations: [],
59
+ effects: ['effect.cancelActiveTasks'],
60
+ reason: 'accepted',
61
+ };
62
+ }
63
+ const input = lifecycleInput(facts);
64
+ return dispatchJobLifecycle(job, input.event, graphFacts(job, facts), executeEffect, persist);
19
65
  }
@@ -3,11 +3,14 @@ import { jobLifecycleV2, taskLifecycleV2 } from './definitions-v2.js';
3
3
  import { dispatchWorkflowGraph, dispatchWorkflowGraphSync } from './dispatcher.js';
4
4
  import { applyJobGraphTarget, applyTaskGraphTarget, applyTaskMutation } from './runtime.js';
5
5
  function v2WorkflowDecision(runtime, from, event, facts = {}) {
6
+ // Pure lifecycle predicates still enter through the dispatcher facade, but
7
+ // suppress observability because no durable transition is being applied.
6
8
  const actions = [];
7
9
  const decision = dispatchWorkflowGraphSync(runtime, {
8
10
  initialState: from,
9
11
  input: { transport: 'event', id: event, payload: {} },
10
12
  durableFacts: facts,
13
+ observe: false,
11
14
  executeAction: actionId => { actions.push(actionId); return {}; },
12
15
  });
13
16
  if (!decision.accepted)
@@ -34,6 +37,7 @@ export function dispatchTaskLifecycleSync(task, event, facts = {}) {
34
37
  initialState: task.state,
35
38
  input: { transport: 'event', id: event, payload: {} },
36
39
  durableFacts: facts,
40
+ graphName: 'task-lifecycle',
37
41
  persistTransition: (_source, target) => applyTaskGraphTarget(task, target),
38
42
  executeAction: actionId => { actions.push(actionId); if (actionId.startsWith('mutation.'))
39
43
  applyTaskMutation(task, actionId); return {}; },
@@ -46,6 +50,7 @@ export async function dispatchTaskLifecycle(task, event, facts = {}, executeEffe
46
50
  const actions = [];
47
51
  const result = await dispatchWorkflowGraph(taskLifecycleV2, {
48
52
  initialState: task.state, input: { transport: 'event', id: event, payload: {} }, durableFacts: facts,
53
+ graphName: 'task-lifecycle',
49
54
  persistTransition: async (_source, target) => { applyTaskGraphTarget(task, target); await persist?.(); },
50
55
  executeAction: async (actionId) => {
51
56
  actions.push(actionId);
@@ -66,6 +71,7 @@ export function dispatchJobLifecycleSync(job, event, facts = {}) {
66
71
  initialState: job.state,
67
72
  input: { transport: 'event', id: event, payload: {} },
68
73
  durableFacts: facts,
74
+ graphName: 'job-lifecycle',
69
75
  persistTransition: (_source, target) => applyJobGraphTarget(job, target),
70
76
  executeAction: actionId => { actions.push(actionId); return {}; },
71
77
  });
@@ -77,6 +83,7 @@ export async function dispatchJobLifecycle(job, event, facts = {}, executeEffect
77
83
  const actions = [];
78
84
  const result = await dispatchWorkflowGraph(jobLifecycleV2, {
79
85
  initialState: job.state, input: { transport: 'event', id: event, payload: {} }, durableFacts: facts,
86
+ graphName: 'job-lifecycle',
80
87
  persistTransition: async (_source, target) => { applyJobGraphTarget(job, target); await persist?.(); },
81
88
  executeAction: async (actionId) => { actions.push(actionId); await executeEffect?.(actionId); return {}; },
82
89
  });
@@ -27,10 +27,10 @@ export const plannerOrchestrationV2 = new GraphRuntime(source, {
27
27
  events: { 'planner.reconcile': { id: 'planner.reconcile' } },
28
28
  });
29
29
  export async function dispatchPlannerOrchestration(facts, executeAction) {
30
- return dispatchWorkflowGraph(plannerOrchestrationV2, { initialState: 'idle', input: { transport: 'event', id: 'planner.reconcile', payload: {} }, durableFacts: facts, isTransientState: state => state.startsWith('check_'), maxSteps: 9, executeAction });
30
+ return dispatchWorkflowGraph(plannerOrchestrationV2, { initialState: 'idle', input: { transport: 'event', id: 'planner.reconcile', payload: {} }, durableFacts: facts, graphName: 'planner-orchestration', isTransientState: state => state.startsWith('check_'), maxSteps: 9, executeAction });
31
31
  }
32
32
  export function decidePlannerOrchestration(facts) {
33
- return dispatchWorkflowGraphSync(plannerOrchestrationV2, { initialState: 'idle', input: { transport: 'event', id: 'planner.reconcile', payload: {} }, durableFacts: facts, isTransientState: state => state.startsWith('check_'), maxSteps: 9 });
33
+ return dispatchWorkflowGraphSync(plannerOrchestrationV2, { initialState: 'idle', input: { transport: 'event', id: 'planner.reconcile', payload: {} }, durableFacts: facts, graphName: 'planner-orchestration', isTransientState: state => state.startsWith('check_'), maxSteps: 9 });
34
34
  }
35
35
  export function plannerOrchestrationFacts(job, recoverablePendingPlan) {
36
36
  const slot = job.schedule?.currentRunSlot ? Date.parse(job.schedule.currentRunSlot) : Number.NaN;
@@ -16,7 +16,7 @@ export const plannerTriggerV2 = new GraphRuntime(source, {
16
16
  });
17
17
  export function dispatchPlannerTrigger(facts) {
18
18
  return dispatchWorkflowGraphSync(plannerTriggerV2, {
19
- initialState: 'idle', input: { transport: 'event', id: 'plan.requested', payload: {} }, durableFacts: facts,
19
+ initialState: 'idle', input: { transport: 'event', id: 'plan.requested', payload: {} }, durableFacts: facts, graphName: 'planner-trigger',
20
20
  isTransientState: state => state.startsWith('check_'), maxSteps: 4,
21
21
  });
22
22
  }
@@ -35,8 +35,8 @@ export function reviewOutcome(state) {
35
35
  return state.toLowerCase();
36
36
  }
37
37
  export async function dispatchReviewLifecycle(outcome, event, facts = {}, executeAction) {
38
- return dispatchWorkflowGraph(reviewLifecycleV2, { initialState: reviewGraphState(outcome), input: { transport: 'event', id: event, payload: {} }, durableFacts: facts, executeAction });
38
+ return dispatchWorkflowGraph(reviewLifecycleV2, { initialState: reviewGraphState(outcome), input: { transport: 'event', id: event, payload: {} }, durableFacts: facts, graphName: 'review-lifecycle', executeAction });
39
39
  }
40
40
  export function decideReviewLifecycle(outcome, event, facts = {}) {
41
- return dispatchWorkflowGraphSync(reviewLifecycleV2, { initialState: reviewGraphState(outcome), input: { transport: 'event', id: event, payload: {} }, durableFacts: facts });
41
+ return dispatchWorkflowGraphSync(reviewLifecycleV2, { initialState: reviewGraphState(outcome), input: { transport: 'event', id: event, payload: {} }, durableFacts: facts, graphName: 'review-lifecycle' });
42
42
  }
@@ -14,7 +14,7 @@ export const scheduleCycleV2 = new GraphRuntime(source, {
14
14
  });
15
15
  export function dispatchScheduleCycle(facts, onFinish) {
16
16
  return dispatchWorkflowGraphSync(scheduleCycleV2, {
17
- initialState: 'idle', input: { transport: 'event', id: 'schedule.reconcile', payload: {} }, durableFacts: facts,
17
+ initialState: 'idle', input: { transport: 'event', id: 'schedule.reconcile', payload: {} }, durableFacts: facts, graphName: 'schedule-cycle',
18
18
  isTransientState: state => state.startsWith('check_'), maxSteps: 2,
19
19
  executeAction: actionId => { if (actionId === 'schedule.finishSlot')
20
20
  onFinish();
@@ -23,8 +23,8 @@ export const scheduledRunV2 = new GraphRuntime(source, {
23
23
  events: { 'schedule.tick': { id: 'schedule.tick' } },
24
24
  });
25
25
  export async function dispatchScheduledRun(facts, executeAction) {
26
- return dispatchWorkflowGraph(scheduledRunV2, { initialState: 'idle', input: { transport: 'event', id: 'schedule.tick', payload: {} }, durableFacts: facts, isTransientState: state => state.startsWith('check_'), maxSteps: 8, executeAction });
26
+ return dispatchWorkflowGraph(scheduledRunV2, { initialState: 'idle', input: { transport: 'event', id: 'schedule.tick', payload: {} }, durableFacts: facts, graphName: 'scheduled-run', isTransientState: state => state.startsWith('check_'), maxSteps: 8, executeAction });
27
27
  }
28
28
  export function decideScheduledRun(facts) {
29
- return dispatchWorkflowGraphSync(scheduledRunV2, { initialState: 'idle', input: { transport: 'event', id: 'schedule.tick', payload: {} }, durableFacts: facts, isTransientState: state => state.startsWith('check_'), maxSteps: 8 });
29
+ return dispatchWorkflowGraphSync(scheduledRunV2, { initialState: 'idle', input: { transport: 'event', id: 'schedule.tick', payload: {} }, durableFacts: facts, graphName: 'scheduled-run', isTransientState: state => state.startsWith('check_'), maxSteps: 8 });
30
30
  }
@@ -36,6 +36,7 @@ export function decideRelatedTaskAction(task, candidate, actionId) {
36
36
  initialState: 'evaluate',
37
37
  input: { transport: 'action', id: actionId, payload: {} },
38
38
  durableFacts: relatedTaskActionFacts(task, candidate),
39
+ graphName: 'task-related-action',
39
40
  isTransientState: state => state === 'check_other' || state === 'check_recovery',
40
41
  maxSteps: 3,
41
42
  });
@@ -18,7 +18,7 @@ export const troubleshooterV2 = new GraphRuntime(source, {
18
18
  });
19
19
  export function dispatchTroubleshooter(facts, onCreate) {
20
20
  return dispatchWorkflowGraphSync(troubleshooterV2, {
21
- initialState: 'idle', input: { transport: 'event', id: 'troubleshooter.reconcile', payload: {} }, durableFacts: facts,
21
+ initialState: 'idle', input: { transport: 'event', id: 'troubleshooter.reconcile', payload: {} }, durableFacts: facts, graphName: 'troubleshooter',
22
22
  isTransientState: state => state.startsWith('check_'), maxSteps: 6,
23
23
  executeAction: actionId => { if (actionId === 'troubleshooter.create')
24
24
  onCreate();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.37",
3
+ "version": "2.0.39",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,3 +37,11 @@ A **P0 job** (alias: **self job**) is executed directly by the current active or
37
37
  6. After implementation and local verification, the orchestrator merges the job itself and completes normal post-merge reconciliation.
38
38
 
39
39
  `job create` still only creates durable job state; the P0 workflow requires the orchestrator to follow it immediately with task creation and execution.
40
+
41
+ ## Merge completion vs deployment evidence
42
+
43
+ For a normal one-shot managed Job, the canonical Job lifecycle graph owns completion: a durable `pr.merged` fact transitions the Job to `COMPLETED`. Reconciler and deployment code must not invent a second completion gate after merge.
44
+
45
+ Post-merge publication and deployment activity is separate from Job lifecycle. Events or evidence such as `package.published`, published version metadata, service restart, and deployment verification may be attached after the Job is already `COMPLETED`; they must not hold the Job open, reopen it, or redefine completion. If post-merge deployment needs remediation, record that as separate deployment/recovery work rather than mutating the completed implementation Job lifecycle.
46
+
47
+ A PR closed without merge follows the same graph-owned rule through `pr.closed -> CANCELLED`.