agents-relay 2.0.108 → 2.0.110
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-support.js +7 -0
- package/dist/cli-unblock.js +11 -2
- package/dist/cli.js +32 -4
- package/dist/job-task-id-sequence.js +2 -2
- package/dist/local-job-path.js +0 -7
- package/dist/local-job-registry.js +3 -3
- package/dist/local-managed-store.js +4 -4
- package/dist/managed-worker-input.js +2 -2
- package/dist/reconciler.js +3 -0
- package/dist/relay-store-discovery.js +35 -58
- package/dist/relay-store.js +11 -5
- package/dist/relayd-control.js +26 -0
- package/dist/relayd.js +5 -5
- package/dist/review-terminal-normalization.js +4 -0
- package/dist/service-publication.js +2 -2
- package/dist/task-recovery.js +105 -0
- package/dist/unblock.js +10 -0
- package/dist/unified-relay-schema.js +26 -0
- package/package.json +5 -1
- package/skills/agents-relay/SKILL.md +4 -2
- package/skills/agents-relay/agents/implementation.agent.md +3 -0
- package/skills/agents-relay/agents/reviewer.agent.md +11 -0
- package/skills/agents-relay/agents/troubleshooter.agent.md +2 -2
- package/skills/agents-relay/contracts/event-contract.md +30 -0
- package/skills/agents-relay/contracts/task-recovery-flows.md +135 -0
package/dist/cli-support.js
CHANGED
|
@@ -247,6 +247,13 @@ export async function publishTaskUnblocked(bus, job, taskId, reason) { if (!bus)
|
|
|
247
247
|
catch (error) {
|
|
248
248
|
process.stderr.write(`warning: unblock event unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
249
249
|
} }
|
|
250
|
+
export async function publishTaskBlocked(bus, job, taskId, reason, data) { if (!bus)
|
|
251
|
+
return; try {
|
|
252
|
+
await bus.publish(eventFor(job.id, taskId, null, 'task.blocked', 'blocked', `Task ${taskId} blocked: ${reason}`, 'orchestrator', { repository: job.repository, prNumber: job.prNumber, reason, ...data }));
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
process.stderr.write(`warning: block event unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
256
|
+
} }
|
|
250
257
|
export async function publishDependentUnblocked(bus, targets, reason) { if (!bus)
|
|
251
258
|
return; for (const target of targets) {
|
|
252
259
|
try {
|
package/dist/cli-unblock.js
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { arg, publishTaskUnblocked, publishWake, saveLocal, withEventBus } from './cli-support.js';
|
|
2
2
|
import { isEffectivelyBlocked, unblockAutonomousTasks, unblockTask } from './unblock.js';
|
|
3
|
+
import { resumeTroubleshooter } from './task-recovery.js';
|
|
3
4
|
export async function runTaskUnblockCommand(args, store, job, task, localFile) {
|
|
4
5
|
const reason = arg(args, '--reason');
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
const response = arg(args, '--response');
|
|
7
|
+
if (!reason.trim() && !response.trim())
|
|
8
|
+
throw new Error('task unblock requires --reason or --response');
|
|
9
|
+
if (task.agentName === 'troubleshooter' && task.escalation?.status === 'pending' && response.trim()) {
|
|
10
|
+
const result = await resumeTroubleshooter(store, job.id, task.id, response);
|
|
11
|
+
await saveLocal(localFile, result.job);
|
|
12
|
+
await withEventBus(args, async (bus) => { await publishTaskUnblocked(bus, result.job, task.id, reason || 'Human response recorded'); await publishWake(bus, result.job, task.id, 'Troubleshooter resumed after human response'); });
|
|
13
|
+
console.log(JSON.stringify({ taskId: task.id, resumed: true, task: result.task }, null, 2));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
7
16
|
const result = await unblockTask({ store, job, task, trigger: { source: 'cli', reason } });
|
|
8
17
|
const refreshed = await store.load(job.id);
|
|
9
18
|
await saveLocal(localFile, refreshed);
|
package/dist/cli.js
CHANGED
|
@@ -41,6 +41,7 @@ import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg,
|
|
|
41
41
|
export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
|
|
42
42
|
import { workerTaskFromArgs } from './cli-worker-task.js';
|
|
43
43
|
import { taskExecutionContext } from './task-context.js';
|
|
44
|
+
import { requestTroubleshooterEscalation, resumeTroubleshooter } from './task-recovery.js';
|
|
44
45
|
import { pauseJob, resumeJob } from './job-pause.js';
|
|
45
46
|
export { ensureManagedGitHubJob } from './cli-job-create.js';
|
|
46
47
|
async function runManagedTaskCommand(action, args) {
|
|
@@ -69,7 +70,7 @@ async function runManagedTaskCommand(action, args) {
|
|
|
69
70
|
console.log(JSON.stringify(task, null, 2));
|
|
70
71
|
}
|
|
71
72
|
async function runTaskCommand(action, args) {
|
|
72
|
-
if (!['create', 'update', 'show', 'context', 'comment', 'comments', 'list', 'retry', 'recover', 'unblock', 'cancel', 'supersede'].includes(action))
|
|
73
|
+
if (!['create', 'update', 'show', 'context', 'comment', 'comments', 'list', 'retry', 'recover', 'unblock', 'escalate', 'resume-troubleshooter', 'cancel', 'supersede'].includes(action))
|
|
73
74
|
throw new Error(usage);
|
|
74
75
|
const managed = !arg(args, '--file') && Boolean(arg(args, '--repo') && arg(args, '--id'));
|
|
75
76
|
if (managed) {
|
|
@@ -142,9 +143,24 @@ async function runTaskCommand(action, args) {
|
|
|
142
143
|
if (!taskId)
|
|
143
144
|
throw new Error('task unblock requires --task-id');
|
|
144
145
|
const reason = arg(args, '--reason');
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
146
|
+
const response = arg(args, '--response');
|
|
147
|
+
if (!reason.trim() && !response.trim())
|
|
148
|
+
throw new Error('task unblock requires --reason or --response');
|
|
149
|
+
const job = await relayControlCommand(args, { command: 'task.unblock', jobId, taskId, reason, ...(response.trim() ? { response } : {}) });
|
|
150
|
+
console.log(JSON.stringify(job.tasks.find(item => item.id === taskId), null, 2));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (action === 'escalate') {
|
|
154
|
+
if (!taskId)
|
|
155
|
+
throw new Error('task escalate requires --task-id');
|
|
156
|
+
const job = await relayControlCommand(args, { command: 'task.escalate', jobId, taskId, requiredAction: arg(args, '--required-action'), resolutionCondition: arg(args, '--resolution-condition'), context: arg(args, '--context') });
|
|
157
|
+
console.log(JSON.stringify(job.tasks.find(item => item.id === taskId), null, 2));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (action === 'resume-troubleshooter') {
|
|
161
|
+
if (!taskId)
|
|
162
|
+
throw new Error('task resume-troubleshooter requires --task-id');
|
|
163
|
+
const job = await relayControlCommand(args, { command: 'task.resume-troubleshooter', jobId, taskId, response: arg(args, '--response') });
|
|
148
164
|
console.log(JSON.stringify(job.tasks.find(item => item.id === taskId), null, 2));
|
|
149
165
|
return;
|
|
150
166
|
}
|
|
@@ -235,6 +251,18 @@ async function runTaskCommand(action, args) {
|
|
|
235
251
|
await runTaskUnblockCommand(args, store, job, task, localFile);
|
|
236
252
|
return;
|
|
237
253
|
}
|
|
254
|
+
if (action === 'escalate') {
|
|
255
|
+
const result = await requestTroubleshooterEscalation(store, job.id, id, { requiredAction: arg(args, '--required-action'), resolutionCondition: arg(args, '--resolution-condition'), context: arg(args, '--context') });
|
|
256
|
+
await saveLocal(localFile, result.job);
|
|
257
|
+
console.log(JSON.stringify(result.task, null, 2));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (action === 'resume-troubleshooter') {
|
|
261
|
+
const result = await resumeTroubleshooter(store, job.id, id, arg(args, '--response'));
|
|
262
|
+
await saveLocal(localFile, result.job);
|
|
263
|
+
console.log(JSON.stringify(result.task, null, 2));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
238
266
|
if (action === 'retry' || action === 'recover' || action === 'cancel') {
|
|
239
267
|
let outputJob = job;
|
|
240
268
|
let outputTask = task;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
2
|
import { mkdirSync } from 'node:fs';
|
|
3
|
-
import {
|
|
3
|
+
import { relayUnifiedStorePath } from './relay-store-discovery.js';
|
|
4
4
|
function validateRepository(repository) {
|
|
5
5
|
const [owner, repo, extra] = repository.split('/');
|
|
6
6
|
if (!owner || !repo || extra || !/^[\w.-]+$/.test(owner) || !/^[\w.-]+$/.test(repo) || owner === '..' || repo === '..')
|
|
@@ -19,7 +19,7 @@ export function allocateEntityId(directory, repository, kind, requestKey, occupi
|
|
|
19
19
|
if (requestKey !== undefined && (!requestKey || requestKey.length > 512))
|
|
20
20
|
throw new Error('Invalid idempotency key');
|
|
21
21
|
mkdirSync(directory, { recursive: true });
|
|
22
|
-
const db = new DatabaseSync(
|
|
22
|
+
const db = new DatabaseSync(relayUnifiedStorePath(directory));
|
|
23
23
|
try {
|
|
24
24
|
db.exec('PRAGMA journal_mode=WAL; PRAGMA busy_timeout=10000;');
|
|
25
25
|
db.exec('CREATE TABLE IF NOT EXISTS sequences (repository TEXT NOT NULL, kind TEXT NOT NULL, value INTEGER NOT NULL, PRIMARY KEY(repository,kind)); CREATE TABLE IF NOT EXISTS idempotency (repository TEXT NOT NULL, kind TEXT NOT NULL, request_key TEXT NOT NULL, entity_id TEXT NOT NULL, PRIMARY KEY(repository,kind,request_key));');
|
package/dist/local-job-path.js
CHANGED
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
1
|
import { homedir } from 'node:os';
|
|
3
2
|
import { join } from 'node:path';
|
|
4
|
-
/** Primary local store key is immutable Job ID, independent of a PR number. */
|
|
5
|
-
export function relayJobStorePath(directory, jobId) {
|
|
6
|
-
if (!jobId.trim() || jobId.length > 1024)
|
|
7
|
-
throw new Error('Invalid immutable Job ID');
|
|
8
|
-
return directory + '/job-' + createHash('sha256').update(jobId).digest('hex') + '.db';
|
|
9
|
-
}
|
|
10
3
|
export function relayStoreDirectory(env = process.env) {
|
|
11
4
|
return env.AGENTS_RELAY_STORE_DIR || join(homedir(), '.agents-relay', 'stores');
|
|
12
5
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { GitHubStore, InMemoryStore } from './store.js';
|
|
2
2
|
import { LocalAuthorityDurableStore } from './local-authority-store.js';
|
|
3
|
-
import { discoverLocalRelayStoreSnapshots,
|
|
3
|
+
import { discoverLocalRelayStoreSnapshots, SQLiteRelayStore } from './relay-store.js';
|
|
4
4
|
/** Resolve exactly one SQLite-backed Job from its immutable ID, independent of PR binding. */
|
|
5
5
|
export function createJobIdRegistryResolver(directory, openStores, options = {}) {
|
|
6
6
|
return async (jobId) => {
|
|
@@ -12,10 +12,10 @@ export function createJobIdRegistryResolver(directory, openStores, options = {})
|
|
|
12
12
|
if (!selected)
|
|
13
13
|
return null;
|
|
14
14
|
const { snapshot, job } = selected;
|
|
15
|
-
const scopedKey =
|
|
15
|
+
const scopedKey = `${snapshot.path}#${job.id}`;
|
|
16
16
|
let relay = openStores.get(scopedKey);
|
|
17
17
|
if (!relay) {
|
|
18
|
-
relay = new SQLiteRelayStore(snapshot.path,
|
|
18
|
+
relay = new SQLiteRelayStore(snapshot.path, job.id);
|
|
19
19
|
openStores.set(scopedKey, relay);
|
|
20
20
|
}
|
|
21
21
|
// SQLite remains lifecycle authority. A positive PR binding only enables
|
|
@@ -2,16 +2,16 @@ import { GhApiClient } from './github-client.js';
|
|
|
2
2
|
import { DisabledGitHubClient, noGitHubMode } from './no-github.js';
|
|
3
3
|
import { GitHubStore } from './store.js';
|
|
4
4
|
import { LocalAuthorityDurableStore } from './local-authority-store.js';
|
|
5
|
-
import { relayStoreDirectory,
|
|
5
|
+
import { relayStoreDirectory, relayUnifiedStorePath, resolveUnifiedJobScope, SQLiteRelayStore } from './relay-store.js';
|
|
6
6
|
/** Open the canonical V2 SQLite authority without making any GitHub request. */
|
|
7
7
|
export function openLocalManagedStore(repository, prNumber, options = {}) {
|
|
8
8
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || repository.includes('..') || !Number.isInteger(prNumber) || prNumber < 0 || (prNumber === 0 && !options.jobId)) {
|
|
9
9
|
throw new Error('Local managed store requires a repository and either a positive PR number or immutable jobId');
|
|
10
10
|
}
|
|
11
11
|
const directory = options.directory ?? relayStoreDirectory();
|
|
12
|
-
const
|
|
13
|
-
const scope =
|
|
14
|
-
const relay = new SQLiteRelayStore(
|
|
12
|
+
const path = relayUnifiedStorePath(directory);
|
|
13
|
+
const scope = resolveUnifiedJobScope(directory, repository, prNumber, options.jobId);
|
|
14
|
+
const relay = new SQLiteRelayStore(path, scope);
|
|
15
15
|
const client = prNumber === 0 || options.noGitHub || noGitHubMode() ? new DisabledGitHubClient() : options.client ?? new GhApiClient();
|
|
16
16
|
const github = new GitHubStore(client, repository, prNumber, options.trustedAuthors ?? new Set());
|
|
17
17
|
return {
|
|
@@ -68,8 +68,8 @@ ${task.adapter === 'chatgpt' ? `Because ChatGPT execution continues asynchronous
|
|
|
68
68
|
Before returning or allowing the worker process to exit, you MUST publish exactly one canonical terminal event:
|
|
69
69
|
- task.completed only after the declared output is durable;
|
|
70
70
|
- task.failed for implementation, tool, command, test, patch, commit, push, runtime, or other execution failure that prevents completion;
|
|
71
|
-
- task.
|
|
72
|
-
-
|
|
71
|
+
- task.completed or task.failed is the only agent terminal result. Relay owns task.blocked/task.unblocked lifecycle transitions; never publish task.blocked for a recovery request;
|
|
72
|
+
- Relay-owned cancellation is not an agent terminal result; if execution cannot continue, publish task.failed.
|
|
73
73
|
|
|
74
74
|
If any execution step fails and you cannot continue, publish task.failed with the concrete reason BEFORE exiting.
|
|
75
75
|
Never exit after task.started without publishing exactly one terminal task event.
|
package/dist/reconciler.js
CHANGED
|
@@ -80,6 +80,9 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
80
80
|
this.reconciling = true;
|
|
81
81
|
try {
|
|
82
82
|
const job = await this.store.load(jobId);
|
|
83
|
+
for (const live of [...this.live.values()])
|
|
84
|
+
if (live.jobId === jobId && job.tasks.some(task => task.id === live.taskId && task.state === 'BLOCKED'))
|
|
85
|
+
this.cancelExecutionGeneration(jobId, live.taskId, live.executionId);
|
|
83
86
|
const previousState = job.state;
|
|
84
87
|
const lifecycleFingerprint = (value) => `${value.state}|${value.tasks.map(task => `${task.id}:${task.state}`).join(',')}`;
|
|
85
88
|
const beforeFingerprint = lifecycleFingerprint(job);
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
-
import { dirname, join } from 'node:path';
|
|
3
2
|
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
4
5
|
import { normalizeLegacyJobPrNumber } from './job-pr-number-migration.js';
|
|
5
|
-
/** Unified mode must be opted into only after the complete offline migration passes. */
|
|
6
|
-
export function unifiedRelayStoreEnabled(env = process.env) {
|
|
7
|
-
return env.AGENTS_RELAY_SQLITE_MODE === 'unified';
|
|
8
|
-
}
|
|
9
6
|
export function relayUnifiedStorePath(directory) { return join(dirname(directory), 'relay.sqlite'); }
|
|
10
7
|
/** Resolve one Job scope by repository and PR, refusing ambiguous matches. */
|
|
11
8
|
export function resolveUnifiedJobScope(directory, repository, prNumber, hintId) {
|
|
12
|
-
const
|
|
9
|
+
const path = relayUnifiedStorePath(directory);
|
|
10
|
+
if (hintId && !existsSync(path))
|
|
11
|
+
return hintId;
|
|
12
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
13
13
|
try {
|
|
14
|
+
const hasRegistry = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='source_registry'").get();
|
|
15
|
+
if (!hasRegistry) {
|
|
16
|
+
if (hintId)
|
|
17
|
+
return hintId;
|
|
18
|
+
throw new Error(`Unified Job scope is missing for ${repository} PR ${prNumber}; supply immutable Job ID`);
|
|
19
|
+
}
|
|
14
20
|
if (hintId) {
|
|
15
21
|
const binding = db.prepare('SELECT repository,pr_number FROM source_registry WHERE job_id=?').get(hintId);
|
|
16
22
|
if (binding && (binding.repository !== repository || (prNumber > 0 && binding.pr_number !== prNumber)))
|
|
@@ -26,68 +32,39 @@ export function resolveUnifiedJobScope(directory, repository, prNumber, hintId)
|
|
|
26
32
|
db.close();
|
|
27
33
|
}
|
|
28
34
|
}
|
|
29
|
-
/** Canonical on-disk Relay Store location for one managed GitHub pull request. */
|
|
30
|
-
export function relayStorePath(directory, repository, prNumber) {
|
|
31
|
-
return directory + '/' + `${repository.replace('/', '_')}-${prNumber}.db`;
|
|
32
|
-
}
|
|
33
35
|
/**
|
|
34
36
|
* SQLite is the only job registry. Inventory queries must be strictly read-only:
|
|
35
37
|
* opening a writable RelayStore here would reset in-flight projection claims
|
|
36
38
|
* on every dashboard refresh and could replay external merge side effects.
|
|
37
39
|
*/
|
|
38
40
|
export async function discoverLocalRelayStoreSnapshots(directory) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const facts = fact ? { state: fact.state, head: fact.head_ref && fact.head_sha ? { ref: fact.head_ref, sha: fact.head_sha } : null, observedAt: fact.observed_at } : null;
|
|
50
|
-
const publications = {};
|
|
51
|
-
if (allPublications[job.id])
|
|
52
|
-
publications[job.id] = allPublications[job.id];
|
|
53
|
-
snapshots.push({ path, jobs: [job], facts, publications });
|
|
54
|
-
}
|
|
55
|
-
return snapshots;
|
|
56
|
-
}
|
|
57
|
-
finally {
|
|
58
|
-
db.close();
|
|
41
|
+
const path = relayUnifiedStorePath(directory);
|
|
42
|
+
if (!existsSync(path)) {
|
|
43
|
+
// Never silently treat legacy per-Job files as an empty authoritative database.
|
|
44
|
+
const files = await readdir(directory).catch(error => {
|
|
45
|
+
if (error.code === 'ENOENT')
|
|
46
|
+
return [];
|
|
47
|
+
throw error;
|
|
48
|
+
});
|
|
49
|
+
if (files.some(name => name.endsWith('.db'))) {
|
|
50
|
+
throw new Error(`Unified Relay SQLite database missing at ${path}; legacy Job databases exist in ${directory}`);
|
|
59
51
|
}
|
|
52
|
+
return [];
|
|
60
53
|
}
|
|
61
|
-
|
|
54
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
62
55
|
try {
|
|
63
|
-
|
|
56
|
+
const rows = db.prepare('SELECT json FROM job_snapshot ORDER BY CAST(job_id AS INTEGER), job_id').all();
|
|
57
|
+
const allPublications = Object.fromEntries(db.prepare('SELECT job_id,json FROM publication_state').all().map(row => [row.job_id, JSON.parse(row.json)]));
|
|
58
|
+
return rows.map(row => {
|
|
59
|
+
const job = normalizeLegacyJobPrNumber(JSON.parse(row.json));
|
|
60
|
+
const fact = db.prepare('SELECT state,head_ref,head_sha,observed_at FROM pull_request_facts WHERE scope_job_id=? AND id=1').get(job.id);
|
|
61
|
+
return { path, jobs: [job], facts: fact ? { state: fact.state, head: fact.head_ref && fact.head_sha ? { ref: fact.head_ref, sha: fact.head_sha } : null, observedAt: fact.observed_at } : null, publications: allPublications[job.id] ? { [job.id]: allPublications[job.id] } : {} };
|
|
62
|
+
});
|
|
64
63
|
}
|
|
65
|
-
catch {
|
|
66
|
-
|
|
64
|
+
catch (error) {
|
|
65
|
+
throw new Error(`Unified Relay SQLite inventory unreadable at ${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
67
66
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const path = directory + '/' + name;
|
|
71
|
-
let db;
|
|
72
|
-
try {
|
|
73
|
-
db = new DatabaseSync(path, { readOnly: true });
|
|
74
|
-
const rows = db.prepare('SELECT json FROM job_snapshot ORDER BY job_id').all();
|
|
75
|
-
const jobs = rows.map(row => normalizeLegacyJobPrNumber(JSON.parse(row.json)));
|
|
76
|
-
const row = db.prepare('SELECT state,head_ref,head_sha,observed_at FROM pull_request_facts WHERE id=1').get();
|
|
77
|
-
const facts = row ? {
|
|
78
|
-
state: row.state,
|
|
79
|
-
head: row.head_ref && row.head_sha ? { ref: row.head_ref, sha: row.head_sha } : null,
|
|
80
|
-
observedAt: row.observed_at,
|
|
81
|
-
} : null;
|
|
82
|
-
const publications = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='publication_state'").get() ? Object.fromEntries(db.prepare('SELECT job_id,json FROM publication_state').all().map(item => [item.job_id, JSON.parse(item.json)])) : {};
|
|
83
|
-
snapshots.push({ path, jobs, facts, publications });
|
|
84
|
-
}
|
|
85
|
-
catch {
|
|
86
|
-
// Ignore unrelated/corrupt files without allowing one damaged store to block recovery.
|
|
87
|
-
}
|
|
88
|
-
finally {
|
|
89
|
-
db?.close();
|
|
90
|
-
}
|
|
67
|
+
finally {
|
|
68
|
+
db.close();
|
|
91
69
|
}
|
|
92
|
-
return snapshots;
|
|
93
70
|
}
|
package/dist/relay-store.js
CHANGED
|
@@ -7,6 +7,7 @@ import { recordPublicationInDatabase, loadPublicationFromDatabase, listPublicati
|
|
|
7
7
|
import { observability } from './observability.js';
|
|
8
8
|
import { createJobIfAbsent } from './relay-create.js';
|
|
9
9
|
import { ensureUnifiedJobRegistry, createUnifiedJobIfAbsent } from './unified-job-registry.js';
|
|
10
|
+
import { migrateUnifiedRelaySchema } from './unified-relay-schema.js';
|
|
10
11
|
import { saveUnifiedPullRequestFacts } from './unified-pr-facts.js';
|
|
11
12
|
import { migrateLegacyJobPrNumbers, normalizeLegacyJobPrNumber } from './job-pr-number-migration.js';
|
|
12
13
|
import { hostInvocationTablesSql, recordHostInvocationEvent, hasHostInvocationDecision, recordHostInvocationDecision } from './host-invocation-store.js';
|
|
@@ -57,9 +58,14 @@ export class SQLiteRelayStore {
|
|
|
57
58
|
this.db = new DatabaseSync(path);
|
|
58
59
|
this.db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;');
|
|
59
60
|
if (scopeJobId) {
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
|
|
61
|
+
const hasUnifiedSchema = this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='migration_metadata'").get();
|
|
62
|
+
if (!hasUnifiedSchema)
|
|
63
|
+
migrateUnifiedRelaySchema(this.db);
|
|
64
|
+
else {
|
|
65
|
+
const schema = this.db.prepare("SELECT value FROM migration_metadata WHERE key='schema'").get();
|
|
66
|
+
if (schema?.value !== 'single-sqlite-staging-v1')
|
|
67
|
+
throw new Error('Unified Relay schema is missing or unsupported');
|
|
68
|
+
}
|
|
63
69
|
}
|
|
64
70
|
else
|
|
65
71
|
this.migrate();
|
|
@@ -460,5 +466,5 @@ export class SQLiteRelayStore {
|
|
|
460
466
|
async recordProjectionReceipt(operationKey, status, attempts, detail) { this.outbox.recordReceipt(operationKey, status, attempts, detail, new Date().toISOString()); }
|
|
461
467
|
close() { this.db.close(); }
|
|
462
468
|
}
|
|
463
|
-
export {
|
|
464
|
-
export {
|
|
469
|
+
export { relayStoreDirectory } from './local-job-path.js';
|
|
470
|
+
export { relayUnifiedStorePath, resolveUnifiedJobScope, discoverLocalRelayStoreSnapshots } from './relay-store-discovery.js';
|
package/dist/relayd-control.js
CHANGED
|
@@ -3,7 +3,9 @@ import { InMemoryStore } from './store.js';
|
|
|
3
3
|
import { openLocalManagedStore } from './local-managed-store.js';
|
|
4
4
|
import { relayStoreDirectory } from './relay-store.js';
|
|
5
5
|
import { normalizeThreadId } from './reviewer-thread.js';
|
|
6
|
+
import { publishTaskBlocked } from './cli-support.js';
|
|
6
7
|
import { supersedeCancelledTask } from './task-actions.js';
|
|
8
|
+
import { requestTroubleshooterEscalation, resumeTroubleshooter } from './task-recovery.js';
|
|
7
9
|
import { adoptExistingPullRequestJob } from './cli-job-create.js';
|
|
8
10
|
export function createWorkspaceControl(deps) {
|
|
9
11
|
const { client, trusted, noGitHub, repositories, refreshAll, requireLocalJobBinding, makeReconciler, concurrency, pool, bus, isEffectivelyBlocked, unblockAutonomousTasks, unblockTask, publishTaskUnblocked, dispatchTaskAction, registerBlockerDependency, discoverAgents, ensureManagedGitHubJob } = deps;
|
|
@@ -166,12 +168,36 @@ export function createWorkspaceControl(deps) {
|
|
|
166
168
|
const task = job.tasks.find((item) => item.id === taskId);
|
|
167
169
|
if (!task)
|
|
168
170
|
throw new Error(`Task ${taskId} not found`);
|
|
171
|
+
if (task.agentName === 'troubleshooter' && task.escalation?.status === 'pending' && typeof body.response === 'string') {
|
|
172
|
+
const resumed = await resumeTroubleshooter(store, jobId, taskId, body.response);
|
|
173
|
+
await publishTaskUnblocked(bus, resumed.job, taskId, reason || 'Human response recorded');
|
|
174
|
+
await pool.reconcileLocalJob(jobId, 'control.task.unblock.troubleshooter');
|
|
175
|
+
return await store.load(jobId);
|
|
176
|
+
}
|
|
169
177
|
await unblockTask({ store, job, task, trigger: { source: 'cli', reason } });
|
|
170
178
|
const refreshed = await store.load(jobId);
|
|
171
179
|
await publishTaskUnblocked(bus, refreshed, taskId, reason);
|
|
172
180
|
await pool.reconcileLocalJob(jobId, 'control.task.unblock');
|
|
173
181
|
return await store.load(jobId);
|
|
174
182
|
}
|
|
183
|
+
if (command === 'task.escalate') {
|
|
184
|
+
const recoveryTaskId = typeof body.taskId === 'string' ? body.taskId : '';
|
|
185
|
+
const requiredAction = typeof body.requiredAction === 'string' ? body.requiredAction : '';
|
|
186
|
+
const resolutionCondition = typeof body.resolutionCondition === 'string' ? body.resolutionCondition : '';
|
|
187
|
+
const context = typeof body.context === 'string' ? body.context : '';
|
|
188
|
+
const result = await requestTroubleshooterEscalation(store, jobId, recoveryTaskId, { requiredAction, resolutionCondition, context });
|
|
189
|
+
await publishTaskBlocked(bus, result.job, recoveryTaskId, requiredAction, { escalationId: result.escalation.id, originalTaskId: result.escalation.originalTaskId });
|
|
190
|
+
await pool.reconcileLocalJob(jobId, 'control.task.escalate');
|
|
191
|
+
return await store.load(jobId);
|
|
192
|
+
}
|
|
193
|
+
if (command === 'task.resume-troubleshooter') {
|
|
194
|
+
const recoveryTaskId = typeof body.taskId === 'string' ? body.taskId : '';
|
|
195
|
+
const response = typeof body.response === 'string' ? body.response : '';
|
|
196
|
+
const resumed = await resumeTroubleshooter(store, jobId, recoveryTaskId, response);
|
|
197
|
+
await publishTaskUnblocked(bus, resumed.job, recoveryTaskId, 'Human response recorded');
|
|
198
|
+
await pool.reconcileLocalJob(jobId, 'control.task.resume-troubleshooter');
|
|
199
|
+
return await store.load(jobId);
|
|
200
|
+
}
|
|
175
201
|
if (command === 'review.request') {
|
|
176
202
|
if (job.state === 'CANCELLED' || job.state === 'COMPLETED')
|
|
177
203
|
throw new Error('Cannot request review for terminal Job');
|
package/dist/relayd.js
CHANGED
|
@@ -13,7 +13,7 @@ import { Reconciler } from './reconciler.js';
|
|
|
13
13
|
import { dashboardManagedJobs, GitHubStore, InMemoryStore, resolvePullRequest } from './store.js';
|
|
14
14
|
import { GitHubProjectionWriter } from './projected-store.js';
|
|
15
15
|
import { LocalAuthorityDurableStore } from './local-authority-store.js';
|
|
16
|
-
import { discoverLocalRelayStoreSnapshots, relayStoreDirectory,
|
|
16
|
+
import { discoverLocalRelayStoreSnapshots, relayStoreDirectory, relayUnifiedStorePath, resolveUnifiedJobScope, SQLiteRelayStore } from './relay-store.js';
|
|
17
17
|
import { createJobIdRegistryResolver } from './local-job-registry.js';
|
|
18
18
|
import { projectAuthoritativeLocalMerge } from './local-merge-projection.js';
|
|
19
19
|
import { archiveDisplayPullRequest } from './github-pr-archive.js';
|
|
@@ -116,8 +116,8 @@ export async function runDaemon(argv) {
|
|
|
116
116
|
const key = `${repository}#${prNumber}`;
|
|
117
117
|
let relay = relayStores.get(key);
|
|
118
118
|
if (!relay) {
|
|
119
|
-
const scope =
|
|
120
|
-
relay = new SQLiteRelayStore(
|
|
119
|
+
const scope = resolveUnifiedJobScope(relayStoreDirectory(), repository, prNumber);
|
|
120
|
+
relay = new SQLiteRelayStore(relayUnifiedStorePath(relayStoreDirectory()), scope);
|
|
121
121
|
relayStores.set(key, relay);
|
|
122
122
|
}
|
|
123
123
|
const github = new GitHubStore(client, repository, prNumber, trusted, seed);
|
|
@@ -229,10 +229,10 @@ export async function runDaemon(argv) {
|
|
|
229
229
|
if (targetRef !== 'refs/heads/main')
|
|
230
230
|
throw new Error('GitHub sync only supports local main as merge target');
|
|
231
231
|
const branch = sourceRef.slice('refs/heads/'.length), base = targetRef.slice('refs/heads/'.length);
|
|
232
|
-
const scopedKey =
|
|
232
|
+
const scopedKey = `${snapshot.path}#${discovered.id}`;
|
|
233
233
|
let relay = relayStores.get(scopedKey);
|
|
234
234
|
if (!relay) {
|
|
235
|
-
relay = new SQLiteRelayStore(snapshot.path,
|
|
235
|
+
relay = new SQLiteRelayStore(snapshot.path, discovered.id);
|
|
236
236
|
relayStores.set(scopedKey, relay);
|
|
237
237
|
}
|
|
238
238
|
const canonical = repositoryPaths.get(discovered.repository) ?? await resolveManagedProjectPath(discovered.repository, explicitProjectPath, workspaceRoot);
|
|
@@ -18,6 +18,10 @@ export function normalizeReviewerTerminalEvent(event) {
|
|
|
18
18
|
const head = [outcome.head_sha, outcome.headSha, review.head_sha, review.headSha,
|
|
19
19
|
data.head_sha, data.headSha]
|
|
20
20
|
.find(value => typeof value === 'string' && value.length > 0);
|
|
21
|
+
// A unified task.completed review must carry its immutable target explicitly;
|
|
22
|
+
// native legacy review events may still use the task-bound fallback.
|
|
23
|
+
if (!head)
|
|
24
|
+
return null;
|
|
21
25
|
const existingFindings = outcome.blocking_findings ?? outcome.blockingFindings;
|
|
22
26
|
const findings = existingFindings ?? (Array.isArray(data.findings) ? data.findings : undefined);
|
|
23
27
|
return { ...event, type, data: { ...data, outcome: {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { eventFor } from './events.js';
|
|
2
|
-
import { SQLiteRelayStore, relayStoreDirectory,
|
|
2
|
+
import { SQLiteRelayStore, relayStoreDirectory, relayUnifiedStorePath } from './relay-store.js';
|
|
3
3
|
/** Single-Job service uses the same durable publish facts as the repository daemon. */
|
|
4
4
|
export async function handleServicePublication(job, wake, bus, directory = relayStoreDirectory()) {
|
|
5
5
|
if (!wake.publication && !wake.publishFailure)
|
|
6
6
|
return;
|
|
7
|
-
const sqlite = new SQLiteRelayStore(
|
|
7
|
+
const sqlite = new SQLiteRelayStore(relayUnifiedStorePath(directory), job.id);
|
|
8
8
|
try {
|
|
9
9
|
await sqlite.recordPublication(job.id, wake.deliveryId, wake.publication, wake.publishFailure ?? null, wake.publicationObservedAt);
|
|
10
10
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { tryTransitionTaskEvent } from './scheduler.js';
|
|
3
|
+
import { dispatchTaskResolutionSync, materializeResolution } from './workflow/task-resolution.js';
|
|
4
|
+
function activeRecovery(job, original) {
|
|
5
|
+
return job.tasks.find(task => task.agentName === 'troubleshooter'
|
|
6
|
+
&& task.parentTaskId === original.id
|
|
7
|
+
&& ['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED'].includes(task.state));
|
|
8
|
+
}
|
|
9
|
+
function clearExecution(task) {
|
|
10
|
+
task.leaseOwner = null;
|
|
11
|
+
task.leaseExpiresAt = null;
|
|
12
|
+
task.executionId = null;
|
|
13
|
+
}
|
|
14
|
+
function validateRequest(request) {
|
|
15
|
+
if (!request.requiredAction.trim())
|
|
16
|
+
throw new Error('Escalation requires a concrete required action');
|
|
17
|
+
if (!request.resolutionCondition.trim())
|
|
18
|
+
throw new Error('Escalation requires a resolution condition');
|
|
19
|
+
if (!request.context.trim())
|
|
20
|
+
throw new Error('Escalation requires diagnostic context');
|
|
21
|
+
}
|
|
22
|
+
/** Relay-owned escalation: the recovery Task, never the failed original, becomes BLOCKED. */
|
|
23
|
+
export async function requestTroubleshooterEscalation(store, jobId, recoveryTaskId, request, now = new Date().toISOString()) {
|
|
24
|
+
validateRequest(request);
|
|
25
|
+
let result;
|
|
26
|
+
const apply = (job) => {
|
|
27
|
+
const recovery = job.tasks.find(task => task.id === recoveryTaskId);
|
|
28
|
+
if (!recovery || recovery.agentName !== 'troubleshooter' || !recovery.parentTaskId)
|
|
29
|
+
throw new Error(`Task ${recoveryTaskId} is not a Troubleshooter recovery`);
|
|
30
|
+
if (job.state === 'CANCELLED' || job.state === 'COMPLETED')
|
|
31
|
+
throw new Error(`Job ${job.id} is terminal`);
|
|
32
|
+
const original = job.tasks.find(task => task.id === recovery.parentTaskId);
|
|
33
|
+
if (!original || original.state !== 'FAILED')
|
|
34
|
+
throw new Error(`Troubleshooter ${recoveryTaskId} has no current FAILED original Task`);
|
|
35
|
+
if (recovery.escalation?.status === 'pending' && recovery.state === 'BLOCKED') {
|
|
36
|
+
result = { task: structuredClone(recovery), escalation: structuredClone(recovery.escalation), duplicate: true };
|
|
37
|
+
return job;
|
|
38
|
+
}
|
|
39
|
+
if (!['QUEUED', 'RUNNING'].includes(recovery.state))
|
|
40
|
+
throw new Error(`Troubleshooter ${recoveryTaskId} cannot escalate from ${recovery.state}`);
|
|
41
|
+
const proposal = { path: 'escalation_gate', targetTaskId: original.id, rootCause: request.context.trim(), solution: request.requiredAction.trim() };
|
|
42
|
+
const decision = dispatchTaskResolutionSync(original, proposal, 'troubleshooter', {
|
|
43
|
+
createEscalationGate: () => { original.resolution = materializeResolution(original, proposal, 'troubleshooter', now); },
|
|
44
|
+
});
|
|
45
|
+
if (!decision.accepted)
|
|
46
|
+
throw new Error(`Task resolution graph rejected escalation for ${original.id}`);
|
|
47
|
+
if (!tryTransitionTaskEvent(recovery, 'task.blocked').accepted)
|
|
48
|
+
throw new Error(`Troubleshooter ${recoveryTaskId} cannot be blocked`);
|
|
49
|
+
const escalation = {
|
|
50
|
+
id: randomUUID(), originalTaskId: original.id, originalExecutionId: original.executionId ?? undefined,
|
|
51
|
+
recoveryExecutionId: recovery.executionId ?? undefined, requiredAction: request.requiredAction.trim(),
|
|
52
|
+
resolutionCondition: request.resolutionCondition.trim(), context: request.context.trim(), status: 'pending',
|
|
53
|
+
requestedAt: now, notification: request.notification ?? null,
|
|
54
|
+
};
|
|
55
|
+
recovery.escalation = escalation;
|
|
56
|
+
recovery.error = `Waiting for human action: ${escalation.requiredAction}`;
|
|
57
|
+
recovery.failureReason = 'human_action_required';
|
|
58
|
+
clearExecution(recovery);
|
|
59
|
+
recovery.updatedAt = now;
|
|
60
|
+
result = { task: structuredClone(recovery), escalation, duplicate: false };
|
|
61
|
+
job.updatedAt = now;
|
|
62
|
+
return job;
|
|
63
|
+
};
|
|
64
|
+
const job = store.mutateJob ? await store.mutateJob(jobId, apply) : await store.load(jobId).then(async (current) => { apply(current); for (const task of current.tasks)
|
|
65
|
+
await store.saveTask(task); await store.saveJob(current); return current; });
|
|
66
|
+
if (!result)
|
|
67
|
+
throw new Error('Escalation did not produce a durable result');
|
|
68
|
+
return { job, ...result };
|
|
69
|
+
}
|
|
70
|
+
/** Authorized human response; only a blocked Troubleshooter may be resumed. */
|
|
71
|
+
export async function resumeTroubleshooter(store, jobId, recoveryTaskId, response, now = new Date().toISOString()) {
|
|
72
|
+
if (!response.trim())
|
|
73
|
+
throw new Error('Troubleshooter response is required');
|
|
74
|
+
let updated;
|
|
75
|
+
const apply = (job) => {
|
|
76
|
+
const task = job.tasks.find(item => item.id === recoveryTaskId);
|
|
77
|
+
if (!task || task.agentName !== 'troubleshooter' || !task.escalation)
|
|
78
|
+
throw new Error(`Task ${recoveryTaskId} is not a Troubleshooter escalation`);
|
|
79
|
+
if (task.escalation.status !== 'pending')
|
|
80
|
+
throw new Error(`Escalation ${task.escalation.id} already has a response`);
|
|
81
|
+
if (task.state !== 'BLOCKED')
|
|
82
|
+
throw new Error(`Task ${recoveryTaskId} is not a blocked Troubleshooter escalation`);
|
|
83
|
+
if (!tryTransitionTaskEvent(task, 'task.unblocked', { autonomousJob: false }).accepted)
|
|
84
|
+
throw new Error(`Troubleshooter ${recoveryTaskId} cannot be resumed`);
|
|
85
|
+
task.escalation = { ...task.escalation, status: 'responded', response: response.trim(), respondedAt: now };
|
|
86
|
+
task.input += `\n\n## Human response (${now})\n${response.trim()}\n\nRe-validate the original failure against this response. Required resolution condition: ${task.escalation.resolutionCondition}`;
|
|
87
|
+
task.result = null;
|
|
88
|
+
task.error = null;
|
|
89
|
+
task.failureReason = undefined;
|
|
90
|
+
clearExecution(task);
|
|
91
|
+
task.updatedAt = now;
|
|
92
|
+
updated = structuredClone(task);
|
|
93
|
+
job.updatedAt = now;
|
|
94
|
+
return job;
|
|
95
|
+
};
|
|
96
|
+
const job = store.mutateJob ? await store.mutateJob(jobId, apply) : await store.load(jobId).then(async (current) => { apply(current); for (const task of current.tasks)
|
|
97
|
+
await store.saveTask(task); await store.saveJob(current); return current; });
|
|
98
|
+
if (!updated)
|
|
99
|
+
throw new Error('Troubleshooter was not resumed');
|
|
100
|
+
return { job, task: updated };
|
|
101
|
+
}
|
|
102
|
+
export function hasPendingTroubleshooterEscalation(job, original) {
|
|
103
|
+
const recovery = activeRecovery(job, original);
|
|
104
|
+
return recovery?.state === 'BLOCKED' && recovery.escalation?.status === 'pending';
|
|
105
|
+
}
|
package/dist/unblock.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { applyTaskUnblockRetrySnapshot } from './task-actions.js';
|
|
2
2
|
import { tryTransitionTaskEvent } from './scheduler.js';
|
|
3
3
|
import { dispatchAuthoritativeUnblock } from './workflow/authoritative-unblock-v2.js';
|
|
4
|
+
import { hasPendingTroubleshooterEscalation } from './task-recovery.js';
|
|
4
5
|
export function isEffectivelyBlocked(task) { return task.kind !== 'planner' && task.state === 'BLOCKED'; }
|
|
6
|
+
function assertNoPendingHumanEscalation(tasks) {
|
|
7
|
+
if (tasks.some(task => task.agentName === 'troubleshooter' && task.escalation?.status === 'pending'))
|
|
8
|
+
throw new Error('Blocked Troubleshooter requires an authorized human response; use task unblock --response');
|
|
9
|
+
}
|
|
5
10
|
function baselineFor(task) {
|
|
6
11
|
return task.blockedBaseline ?? { blockedAt: task.updatedAt, reason: task.error ?? undefined };
|
|
7
12
|
}
|
|
@@ -59,6 +64,7 @@ export async function unblockAutonomousTasks(options) {
|
|
|
59
64
|
const tasks = options.tasks.filter(isEffectivelyBlocked);
|
|
60
65
|
if (tasks.length === 0)
|
|
61
66
|
throw new Error('No effective BLOCKED work task is available to unblock');
|
|
67
|
+
assertNoPendingHumanEscalation(tasks);
|
|
62
68
|
const now = options.now ?? new Date().toISOString();
|
|
63
69
|
const currentHeadSha = await store.headSha?.() ?? job.headSha ?? null;
|
|
64
70
|
const facts = unblockFacts(job, tasks, currentHeadSha, trigger.reason);
|
|
@@ -95,6 +101,10 @@ export async function unblockAutonomousTasks(options) {
|
|
|
95
101
|
export async function unblockTask(options) {
|
|
96
102
|
if (!isEffectivelyBlocked(options.task))
|
|
97
103
|
throw new Error(`Task ${options.task.id} is not an effective BLOCKED work task`);
|
|
104
|
+
assertNoPendingHumanEscalation([options.task]);
|
|
105
|
+
if (options.task.agentName !== 'troubleshooter' && hasPendingTroubleshooterEscalation(options.job, options.task)) {
|
|
106
|
+
throw new Error(`Task ${options.task.id} cannot be directly unblocked while its Troubleshooter is awaiting human action`);
|
|
107
|
+
}
|
|
98
108
|
let result;
|
|
99
109
|
const decision = await dispatchAuthoritativeUnblock(options.job.executionMode === 'autonomous', async (actionId) => {
|
|
100
110
|
if (actionId === 'unblock.replan') {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { hostInvocationTablesSql } from './host-invocation-store.js';
|
|
2
|
+
/** Bootstrap the single authoritative Relay SQLite schema. */
|
|
3
|
+
export function migrateUnifiedRelaySchema(db) {
|
|
4
|
+
db.exec(`
|
|
5
|
+
CREATE TABLE IF NOT EXISTS source_registry (job_id TEXT PRIMARY KEY, source_file TEXT NOT NULL UNIQUE, repository TEXT NOT NULL, pr_number INTEGER NOT NULL);
|
|
6
|
+
CREATE INDEX IF NOT EXISTS source_registry_repo_pr ON source_registry(repository,pr_number);
|
|
7
|
+
CREATE TABLE IF NOT EXISTS migration_metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
8
|
+
CREATE TABLE IF NOT EXISTS relay_meta (scope_job_id TEXT NOT NULL REFERENCES source_registry(job_id), key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(scope_job_id,key));
|
|
9
|
+
CREATE TABLE IF NOT EXISTS job_snapshot (job_id TEXT PRIMARY KEY, json TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
10
|
+
CREATE INDEX IF NOT EXISTS job_snapshot_updated ON job_snapshot(updated_at);
|
|
11
|
+
CREATE TABLE IF NOT EXISTS pull_request_facts (scope_job_id TEXT NOT NULL REFERENCES source_registry(job_id), id INTEGER NOT NULL CHECK(id=1), state TEXT NOT NULL CHECK(state IN ('OPEN','MERGED','CLOSED')), head_ref TEXT, head_sha TEXT, observed_at TEXT NOT NULL, PRIMARY KEY(scope_job_id,id));
|
|
12
|
+
CREATE TABLE IF NOT EXISTS publication_state (job_id TEXT PRIMARY KEY, json TEXT NOT NULL);
|
|
13
|
+
CREATE TABLE IF NOT EXISTS publication_delivery (job_id TEXT NOT NULL, delivery_id TEXT NOT NULL, type TEXT NOT NULL, json TEXT NOT NULL, observed_at TEXT NOT NULL, PRIMARY KEY(job_id,delivery_id));
|
|
14
|
+
CREATE TABLE IF NOT EXISTS agent_snapshot (scope_job_id TEXT NOT NULL REFERENCES source_registry(job_id), agent_id TEXT NOT NULL, json TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(scope_job_id,agent_id));
|
|
15
|
+
CREATE TABLE IF NOT EXISTS event_receipt (scope_job_id TEXT NOT NULL REFERENCES source_registry(job_id), event_id TEXT NOT NULL, stream TEXT, sequence INTEGER, received_at TEXT NOT NULL, PRIMARY KEY(scope_job_id,event_id));
|
|
16
|
+
CREATE TABLE IF NOT EXISTS execution_process (job_id TEXT NOT NULL, task_id TEXT NOT NULL, execution_id TEXT NOT NULL, lease_owner TEXT NOT NULL, host TEXT NOT NULL, pid INTEGER NOT NULL, pgid INTEGER NOT NULL, start_token TEXT NOT NULL, observed_at TEXT NOT NULL, ended_at TEXT, PRIMARY KEY(job_id,task_id,execution_id));
|
|
17
|
+
${hostInvocationTablesSql}
|
|
18
|
+
CREATE TABLE IF NOT EXISTS projection_outbox (scope_job_id TEXT NOT NULL REFERENCES source_registry(job_id), id INTEGER NOT NULL, idempotency_key TEXT NOT NULL, operation_key TEXT, coalesce_key TEXT, operation TEXT NOT NULL, payload_json TEXT NOT NULL, priority INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','processing','conflict','dead')), attempts INTEGER NOT NULL DEFAULT 0, next_attempt_at TEXT, last_error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, conflict_definition_json TEXT, conflict_evidence_json TEXT, conflict_detected_at TEXT, conflict_disposition TEXT, conflict_resolution_json TEXT, PRIMARY KEY(scope_job_id,id), UNIQUE(scope_job_id,idempotency_key));
|
|
19
|
+
CREATE INDEX IF NOT EXISTS projection_unified_due ON projection_outbox(scope_job_id,status,next_attempt_at,priority DESC,id);
|
|
20
|
+
CREATE INDEX IF NOT EXISTS projection_unified_coalesce ON projection_outbox(scope_job_id,coalesce_key,status);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS projection_receipt (scope_job_id TEXT NOT NULL REFERENCES source_registry(job_id), operation_key TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('succeeded','failed')), attempts INTEGER NOT NULL, detail_json TEXT, recorded_at TEXT NOT NULL, PRIMARY KEY(scope_job_id,operation_key));
|
|
22
|
+
CREATE TABLE IF NOT EXISTS sequences (repository TEXT NOT NULL, kind TEXT NOT NULL, value INTEGER NOT NULL, PRIMARY KEY(repository,kind));
|
|
23
|
+
CREATE TABLE IF NOT EXISTS idempotency (repository TEXT NOT NULL, kind TEXT NOT NULL, request_key TEXT NOT NULL, entity_id TEXT NOT NULL, PRIMARY KEY(repository,kind,request_key));
|
|
24
|
+
INSERT INTO migration_metadata(key,value) VALUES('schema','single-sqlite-staging-v1'),('job_count','0');
|
|
25
|
+
`);
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-relay",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.110",
|
|
4
4
|
"description": "Durable async agent jobs coordinated through GitHub pull requests",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/lalalic/agents-relay.git"
|
|
8
|
+
},
|
|
5
9
|
"type": "module",
|
|
6
10
|
"bin": {
|
|
7
11
|
"agents-relay": "dist/cli.js",
|
|
@@ -9,6 +9,8 @@ Use Agents Relay when an orchestrator needs durable delegated work that survives
|
|
|
9
9
|
|
|
10
10
|
## Required shared contract
|
|
11
11
|
|
|
12
|
+
For unified agent terminal events and role-specific results, see `contracts/event-contract.md` and `contracts/task-recovery-flows.md`.
|
|
13
|
+
|
|
12
14
|
Before creating, planning, reviewing, or managing a job/task, read and follow `contracts/task-job-contract.md`. This is the canonical producer/runtime protocol for story size, lifecycle events, review, and auto-merge.
|
|
13
15
|
|
|
14
16
|
## Core task contract
|
|
@@ -39,7 +41,7 @@ Agents Relay may launch a Codex process or submit a one-shot ChatGPT browser tas
|
|
|
39
41
|
A running agent MUST use the canonical Neo `events-bus` protocol and publish exactly one correlated terminal event before stopping:
|
|
40
42
|
- `task.completed` after the declared output is durable;
|
|
41
43
|
- `task.failed` when the task cannot complete;
|
|
42
|
-
- `task.blocked`
|
|
44
|
+
- Progress is optional; `task.blocked` and `task.unblocked` are Relay-owned lifecycle facts, never agent terminal events.
|
|
43
45
|
|
|
44
46
|
Event publishing is not an Agents Relay CLI responsibility. Sandboxed Codex and hosted ChatGPT workers use the federated `events__publish` MCP tool from `events-bus`; direct non-sandbox local workers may use the caller-provided `NEO_EVENTS_EMIT`. Agents Relay subscribes to the canonical `neo.events.job.<job_id>.>` stream and maps terminal events into durable task state. Relay resolves its native NATS endpoint and subject prefix from the canonical events-bus runtime configuration (with explicit Relay CLI flags remaining authoritative). The same runtime configuration supplies the events-bus HTTP API used for replay/history. Relay and its dashboard must not read the events-bus private history file or maintain a second event-history backend.
|
|
45
47
|
|
|
@@ -48,7 +50,7 @@ The event must carry the exact durable `job_id` and `task_id`. Progress events a
|
|
|
48
50
|
Agents Relay maps those terminal events to durable state:
|
|
49
51
|
- `task.completed` → `SUCCEEDED`
|
|
50
52
|
- `task.failed` → `FAILED`
|
|
51
|
-
|
|
53
|
+
Relay may authoritatively move a Task to `BLOCKED` for an external condition and may resume it only through an authorized unblock operation. A human response resumes the blocked Troubleshooter, not the failed original Task.
|
|
52
54
|
|
|
53
55
|
A model-backed task must not launch without an event bus. If no terminal event arrives before the task timeout, Relay records an explicit timeout failure. A Codex process exit or ChatGPT submission receipt alone never changes the task to a terminal state.
|
|
54
56
|
|
|
@@ -22,3 +22,6 @@ identity supplied by Relay for every lifecycle event.
|
|
|
22
22
|
Run the most relevant tests and checks for the change. Do not claim completion
|
|
23
23
|
without durable implementation evidence, validation results, and a concise
|
|
24
24
|
summary of changed files and remaining risks.
|
|
25
|
+
|
|
26
|
+
Use only `task.completed` or `task.failed` as the terminal agent event;
|
|
27
|
+
block/unblock decisions belong to Relay. See `../contracts/event-contract.md`.
|
|
@@ -16,3 +16,14 @@ Reconstruct all material acceptance requirements from the durable Job objective
|
|
|
16
16
|
## Review Decision
|
|
17
17
|
|
|
18
18
|
Return a concrete approved, changes-required or blocked verdict tied to the declared immutable target, with attributed findings, severity and remediation steps. Do not alter artifacts, generate approvals on behalf of users, or perform publication, merges or external actions. Relay owns the review lifecycle and Task/Job completion.
|
|
19
|
+
|
|
20
|
+
## Unified execution event
|
|
21
|
+
|
|
22
|
+
After completing the review, publish **`task.completed`**, including `data.verdict`
|
|
23
|
+
(`approved`, `changes-required`, or `blocked`), the **exact immutable**
|
|
24
|
+
`data.head_sha`, and actionable `data.findings` for changes required. A
|
|
25
|
+
negative verdict is a successful review, not `task.failed`. If review
|
|
26
|
+
execution fails and no sound verdict can be produced, publish `task.failed`.
|
|
27
|
+
Never emit `review.*` as the agent's terminal event. The event source must
|
|
28
|
+
match the execution identity supplied by Relay. See
|
|
29
|
+
`../contracts/event-contract.md`.
|
|
@@ -29,8 +29,8 @@ Read and follow the shared Task / Job contract and orchestrator P0/self-job work
|
|
|
29
29
|
- For a transient failure, verify the transient condition is gone.
|
|
30
30
|
- For task-local failure, make the smallest durable repair in the original Job/PR when that is within scope.
|
|
31
31
|
- For a shared platform/runtime defect, first create or reuse the owning blocker P0/self-job. If it can be completed inside this recovery execution, fix/verify it and retry the original Task. If it cannot be completed now, register the original Task with `agents-relay blocker add` on that blocker Job and finish this Troubleshooter task; do not stay running while waiting for another Job.
|
|
32
|
-
- For a suspected genuine human/external blocker, do not publish `task.blocked` and do not branch on Neo's result yourself. Invoke `agents-relay escalation` with the original intent, blocker, and relevant context.
|
|
33
|
-
6. For an immediate repair, only after the blocker is removed run `agents-relay task
|
|
32
|
+
- For a suspected genuine human/external blocker, do not publish `task.blocked` and do not branch on Neo's result yourself. Invoke `agents-relay escalation` with the original intent, blocker, and relevant context. The canonical graph owns the `resolved`, `failed`, and `needs_user` outcomes. When it returns `needs_user`, call Relay's `task escalate` operation with the concrete required action, resolution condition, and diagnostic context. Relay records the escalation and blocks this Troubleshooter; no worker remains running while waiting.
|
|
33
|
+
6. For an immediate repair, only after the blocker is removed run `agents-relay task recover` for the original Task with this Troubleshooter's recovery Task ID and reconcile its Job. An authorized human response resumes this same Troubleshooter with its diagnostic context; verify the action before requesting original-task recovery. For deferred repair, rely on the durable blocker relation: merge-mode blockers wake matching dependents after merge; release/deploy/runtime blockers use `--resolution verified` and `agents-relay blocker resolve` only after verification.
|
|
34
34
|
7. Verify durable evidence that the original Task moved: at minimum QUEUED after retry, preferably a new execution / task.started / observable progress.
|
|
35
35
|
8. Publish this Troubleshooter Task's terminal event only after the recovery outcome is durable.
|
|
36
36
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Agents Relay Unified Task Event Contract
|
|
2
|
+
|
|
3
|
+
**Scope:** Relay-specific event meaning and validation. This does **not** define Events Bus transport. The bus only handles generic event identity, type, timestamp, routing as needed, and opaque `data`. No Events Bus change is required when Relay adds agent roles or payload fields.
|
|
4
|
+
|
|
5
|
+
## Producer contract
|
|
6
|
+
|
|
7
|
+
Every Implementation, Reviewer, and Troubleshooter execution publishes `task.started`, optional `task.progress` or `task.heartbeat`, then **exactly one** agent terminal event:
|
|
8
|
+
|
|
9
|
+
- `task.completed`: assigned responsibility finished and its role-specific output is durable, regardless of whether a reviewed artifact passed.
|
|
10
|
+
- `task.failed`: assigned responsibility could not be fulfilled; include failure phase, reason, and evidence in `data`.
|
|
11
|
+
|
|
12
|
+
`task.blocked` and `task.unblocked` are **Relay-owned lifecycle facts**, not agent terminal outcomes. Transport acceptance is not proof Relay accepted the transition. Every lifecycle event must use the exact Job, Task, and execution source identity provided by Relay; stale/duplicate execution events cannot change current state.
|
|
13
|
+
|
|
14
|
+
## Role-specific completed data
|
|
15
|
+
|
|
16
|
+
| Role | `task.completed.data` | Relay action |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| Implementation | Evidence of output/artifact, validation, and summary as required by the Task | Persist Task success and continue dependencies/review |
|
|
19
|
+
| Reviewer | `verdict` (`approved`, `changes-required`, or `blocked`), exact immutable `head_sha`, actionable `findings` as necessary | Validate current immutable review target and apply verdict; defects are a successful Reviewer execution, not `task.failed` |
|
|
20
|
+
| Troubleshooter | Recovery action and durable evidence of verified original Task retry, blocker-Job handoff, or other authorized recovery | Complete recovery Task only; original Task state changes through separately authorized Relay operations |
|
|
21
|
+
|
|
22
|
+
A Reviewer must **not** publish unstructured `task.completed`: missing verdict or immutable head is invalid. Relay normalizes an authenticated structured Reviewer terminal event into its internal Review decision; native `review.*` events may be accepted for backward compatibility.
|
|
23
|
+
|
|
24
|
+
## Troubleshooter human escalation
|
|
25
|
+
|
|
26
|
+
Only a real user action merits escalation. Troubleshooter invokes the Relay escalation operation, specifying the required action, resolution condition, and diagnostic context. Relay durably records the human action and blocks **the Troubleshooter Task**, leaving the original failed Task `FAILED`. This is a Relay state transition, not a third agent terminal event.
|
|
27
|
+
|
|
28
|
+
When the human responds, the **Troubleshooter Task** is authoritatively unblocked and resumed with its prior context plus the response. Troubleshooter independently verifies resolution, requests an authorized original Task recovery, and only then publishes its own `task.completed`. If unresolved, it continues diagnosis or escalates again; human assertion alone is not technical verification.
|
|
29
|
+
|
|
30
|
+
See [task-recovery-flows.md](task-recovery-flows.md) for unified architecture, overall flow, failure recovery, and post-blocked resumption.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Task Failure Recovery and Human Escalation — Agreed Target Design
|
|
2
|
+
|
|
3
|
+
**Status:** Design agreement; not an assertion of current implementation. This document describes two flows. Existing runtime, workflow graphs, and agent instructions may need changes to conform.
|
|
4
|
+
|
|
5
|
+
## Principles and ownership
|
|
6
|
+
|
|
7
|
+
- **Events Bus is neutral.** Its generic event envelope consists of event identity, type, time, and opaque client-defined data (plus any transport metadata needed by the bus). It must not understand Relay Jobs, Tasks, roles, or verdicts.
|
|
8
|
+
- **One agent Task event contract** applies to Implementation, Reviewer, and Troubleshooter: optional progress, then exactly one agent terminal event, `task.completed` or `task.failed`. Role-specific results belong in `data`. A review with findings is a *completed* review, not a failed agent execution.
|
|
9
|
+
- **`task.blocked` and `task.unblocked` are authoritative Relay lifecycle transitions**, not ordinary agent terminal events. A human may authorize unblocking a Troubleshooter but does not directly unblock the failed original Task.
|
|
10
|
+
- **Troubleshooter owns diagnosis and recovery decisions.** It chooses technical repair/retry, blocker-Job dependency, or human escalation. There is no separate Escalator agent.
|
|
11
|
+
- **Relay owns transitions and dispatch.** Troubleshooter requests authorized operations; the reconciler/engine validates and durably applies them. An event arriving over the bus is not proof its transition was applied.
|
|
12
|
+
- Preserve the original failure, exact execution correlation, evidence, and idempotency throughout retries and resumes.
|
|
13
|
+
|
|
14
|
+
## Unified architecture
|
|
15
|
+
|
|
16
|
+
All three agent roles publish through the **same Task event contract**. Events Bus treats `data` as opaque; Relay interprets role-specific results and alone changes durable Task/Job state.
|
|
17
|
+
|
|
18
|
+
```mermaid
|
|
19
|
+
flowchart TD
|
|
20
|
+
I["Implementation"] --> U["Unified Task events"]
|
|
21
|
+
V["Reviewer"] --> U
|
|
22
|
+
T["Troubleshooter"] --> U
|
|
23
|
+
U --> B["Neutral Events Bus"]
|
|
24
|
+
B --> C["Relay event consumer"]
|
|
25
|
+
C --> S["Validate execution identity and role-specific data"]
|
|
26
|
+
S --> W["Relay workflow engine / reconciler"]
|
|
27
|
+
W --> D[("Durable Task and Job state")]
|
|
28
|
+
W --> R["Execution / recovery / human-interaction operations"]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- Agents publish `task.completed` or `task.failed` as terminal execution outcomes; Reviewer verdicts and Troubleshooter recovery evidence are results inside `data`.
|
|
32
|
+
- `task.blocked` / `task.unblocked` describe Relay-authorized state changes, not a third agent terminal outcome.
|
|
33
|
+
- Transport does not interpret verdicts, execution IDs, blocker relations, or recovery decisions.
|
|
34
|
+
|
|
35
|
+
## Overall flow — Task outcome and recovery branches
|
|
36
|
+
|
|
37
|
+
This captures the overall decision tree in the accompanying design screenshot, with the **human-action branch corrected** to the agreed ownership: Relay blocks the **Troubleshooter Task**, not the original failed Task.
|
|
38
|
+
|
|
39
|
+
```mermaid
|
|
40
|
+
flowchart TD
|
|
41
|
+
A["Worker / Reviewer"] --> B{"Task outcome"}
|
|
42
|
+
B -->|Success| C["task.completed"]
|
|
43
|
+
B -->|Failure| D["task.failed"]
|
|
44
|
+
D --> E["Relay dispatches Troubleshooter"]
|
|
45
|
+
E --> F{"Recovery decision"}
|
|
46
|
+
F -->|Technical recovery| G["Fix and verify; request retry"]
|
|
47
|
+
G --> H["Original Task resumes"]
|
|
48
|
+
F -->|Other Job required| I["Create/link blocker Job"]
|
|
49
|
+
I --> J["Dependency resolution"]
|
|
50
|
+
J --> K["Original Task resumes after verification"]
|
|
51
|
+
F -->|Human required| L["Troubleshooter requests escalation"]
|
|
52
|
+
L --> M["Relay records human action and blocks TROUBLESHOOTER"]
|
|
53
|
+
M --> N["Wait for human action"]
|
|
54
|
+
N --> O["Human unblocks TROUBLESHOOTER"]
|
|
55
|
+
O --> P["Troubleshooter resumes; verifies result"]
|
|
56
|
+
P --> Q{"Problem resolved?"}
|
|
57
|
+
Q -->|Yes| R["Relay retries original Task"]
|
|
58
|
+
Q -->|No| F
|
|
59
|
+
R --> S["Original Task resumes"]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The original Task remains `FAILED` while its Troubleshooter is blocked for a human action. Technical and dependent-Job paths may close the Troubleshooter Task after durable recovery or handoff; the human-action path resumes that same recovery responsibility after authorized unblock.
|
|
63
|
+
|
|
64
|
+
## Flow 1 — Failure → Troubleshooter → Recovery
|
|
65
|
+
|
|
66
|
+
```mermaid
|
|
67
|
+
flowchart TD
|
|
68
|
+
A["Implementation / Reviewer task.failed"] --> B["Relay records failure and dispatches Troubleshooter"]
|
|
69
|
+
B --> C["Troubleshooter inspects events, Task, logs; diagnoses cause"]
|
|
70
|
+
C --> D{"Selected resolution"}
|
|
71
|
+
D -->|Repairable now| E["Repair / verify technical cause"]
|
|
72
|
+
E --> F["Request original Task retry"]
|
|
73
|
+
D -->|Needs another Job| G["Create or reuse blocker Job; register dependency"]
|
|
74
|
+
G --> H["Troubleshooter completes after durable registration"]
|
|
75
|
+
D -->|Requires real user action| I["Enter Flow 2: human escalation"]
|
|
76
|
+
F --> J["Relay validates and requeues original Task"]
|
|
77
|
+
J --> K["Troubleshooter completes after durable recovery evidence"]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
1. Original agent reports `task.failed` with useful diagnostic `data`; Relay durably records `FAILED`.
|
|
81
|
+
2. Reconciler starts a correlated Troubleshooter Task. Troubleshooter examines durable Task history and technical evidence before choosing a resolution.
|
|
82
|
+
3. For a technical remedy, Troubleshooter repairs/validates, requests authorized retry, confirms the original Task requeued, and completes **its own** Task.
|
|
83
|
+
4. For a blocker Job, Troubleshooter registers a durable dependency, then completes **its own** Task; the dependency mechanism later wakes the original Task after verified resolution.
|
|
84
|
+
5. Only a **real user action** calls for human escalation (Flow 2). Missing credentials that can be found or configured automatically, crashes, and retries are Troubleshooter work, not escalation.
|
|
85
|
+
6. If Troubleshooter itself encounters an unrecoverable execution failure, it emits `task.failed`; Relay handles that failure without discarding original context.
|
|
86
|
+
|
|
87
|
+
## Flow 2 — Human Escalation → Blocked Troubleshooter → Human Unblock → Original Task Recovery
|
|
88
|
+
|
|
89
|
+
```mermaid
|
|
90
|
+
flowchart TD
|
|
91
|
+
A["Troubleshooter determines real human action is required"] --> B["Troubleshooter triggers escalation request"]
|
|
92
|
+
B --> C["Relay durably records required action, context, and escalation ID"]
|
|
93
|
+
C --> D["Relay marks TROUBLESHOOTER Task BLOCKED"]
|
|
94
|
+
D --> E["Human acts and submits response"]
|
|
95
|
+
E --> F["Human authorizes unblock of TROUBLESHOOTER"]
|
|
96
|
+
F --> G["Relay validates authorization, records response, resumes Troubleshooter"]
|
|
97
|
+
G --> H["Troubleshooter verifies action and re-diagnoses"]
|
|
98
|
+
H --> I{"Original failure now recoverable?"}
|
|
99
|
+
I -->|Yes| J["Troubleshooter requests original Task unblock/retry"]
|
|
100
|
+
J --> K["Relay validates and requeues original Task"]
|
|
101
|
+
K --> L["Troubleshooter task.completed"]
|
|
102
|
+
I -->|No, still needs user| B
|
|
103
|
+
I -->|No, technical issue| M["Continue Flow 1 recovery"]
|
|
104
|
+
M --> H
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
1. **Troubleshooter** selects escalation as its recovery resolution and requests a concrete user action. Escalation is an interaction mechanism, **not a second problem-solving agent**.
|
|
108
|
+
2. Relay durably records the escalation ID, original failed Task/execution, Troubleshooter Task/execution, required human action, resolution condition, communication evidence/status, and audit trail; then it authoritatively moves the **Troubleshooter Task** to `BLOCKED`. The original Task stays `FAILED` (pending recovery), not `BLOCKED` merely because the Troubleshooter awaits a human.
|
|
109
|
+
3. While awaiting human action, no Troubleshooter execution continues running. Notification failures and duplicate requests must not silently create orphaned BLOCKED Tasks.
|
|
110
|
+
4. Human performs the requested action and explicitly unblocks the **Troubleshooter** through an authorized Relay operation. Relay stores the response and resumes Troubleshooter with its previous diagnostic context.
|
|
111
|
+
5. Troubleshooter validates whether the action resolved the problem. Human saying “done” is evidence, not by itself proof of technical resolution (unless the requested action was itself an authoritative approval).
|
|
112
|
+
6. If resolved, Troubleshooter requests Relay to unblock/retry the **original Task**, verifies the durable transition, then emits `task.completed` for its own Task. If unresolved, it continues technical diagnosis or initiates another recorded escalation.
|
|
113
|
+
7. Relay guards against stale execution IDs, cancelled/superseded Tasks, duplicate human responses, and paused Jobs. No old response may resurrect obsolete work.
|
|
114
|
+
|
|
115
|
+
## Event contract versus state transitions
|
|
116
|
+
|
|
117
|
+
| Actor | Action | Meaning |
|
|
118
|
+
| --- | --- | --- |
|
|
119
|
+
| Implementation / Reviewer | `task.completed` | Assigned work finished; Reviewer verdict is in `data` |
|
|
120
|
+
| Implementation / Reviewer | `task.failed` | Execution could not fulfill assigned work |
|
|
121
|
+
| Troubleshooter | `task.completed` | Technical recovery verified, or durable blocker-Job handoff established |
|
|
122
|
+
| Troubleshooter | `task.failed` | Troubleshooter execution failed |
|
|
123
|
+
| Troubleshooter | Escalation request (Relay operation) | Request a specific real human action; does **not** complete its own Task |
|
|
124
|
+
| Relay | Block / unblock Troubleshooter | Authoritative lifecycle actions before/after human interaction |
|
|
125
|
+
| Relay | Retry / unblock original Task | Authorized transition **only after** Troubleshooter establishes recovery |
|
|
126
|
+
| Events Bus | Transport generic events | No knowledge of task roles, blocker semantics, or response structure |
|
|
127
|
+
|
|
128
|
+
`BLOCKED` is a durable Task state and `task.blocked` may be published by Relay as a state-change fact; it is **not** an agent-selected terminal outcome. Human unblocking resumes the blocked Troubleshooter Task rather than terminating or re-creating its recovery responsibility.
|
|
129
|
+
|
|
130
|
+
## Required follow-up implementation alignment
|
|
131
|
+
|
|
132
|
+
- Align `skills/agents-relay/SKILL.md` and role instructions with the two agent terminal events; remove agent-directed `task.blocked` emission.
|
|
133
|
+
- Align escalation graph, CLI, reconciler, and unblock commands so human intervention blocks **Troubleshooter**, not the original failed Task; preserve the original failure and correlated recovery context.
|
|
134
|
+
- Ensure the blocked Troubleshooter can resume after an authorized human unblock rather than being treated as a finished terminal execution.
|
|
135
|
+
- Add integrated checks: technical retry, blocker-Job handoff, human request/block, human unblock/resume, failed verification/re-escalation, stale/duplicate responses, and correct original-Task retry.
|