agents-relay 1.0.176 → 2.0.0
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 +4 -2
- package/dist/adapters.js +27 -2
- package/dist/blockers.js +60 -65
- package/dist/cli-support.js +33 -1
- package/dist/cli.js +72 -86
- package/dist/dashboard-actions.js +13 -35
- package/dist/dashboard-html.js +1 -1
- package/dist/dashboard.js +39 -0
- package/dist/execution-recovery.js +37 -0
- package/dist/graph-runtime.js +1 -0
- package/dist/job-actions.js +53 -0
- package/dist/manual-plan.js +15 -17
- package/dist/planner-runtime.js +135 -0
- package/dist/planner.js +76 -60
- package/dist/pool.js +54 -10
- package/dist/projected-store.js +314 -0
- package/dist/reconciler-review.js +185 -110
- package/dist/reconciler-runtime.js +67 -7
- package/dist/reconciler.js +134 -282
- package/dist/relay-store.js +380 -0
- package/dist/relayd.js +122 -29
- package/dist/runtime-status.js +2 -1
- package/dist/scheduled-jobs.js +40 -31
- package/dist/scheduler.js +19 -18
- package/dist/store.js +1 -1
- package/dist/task-actions.js +139 -0
- package/dist/troubleshooter.js +14 -8
- package/dist/types.js +3 -3
- package/dist/unblock.js +31 -21
- package/dist/workflow/authoritative-unblock-v2.js +20 -0
- package/dist/workflow/blocker-resolution-v2.js +33 -0
- package/dist/workflow/contracts.js +21 -0
- package/dist/workflow/definitions-v2.js +75 -0
- package/dist/workflow/dispatcher.js +56 -0
- package/dist/workflow/entity-creation.js +32 -0
- package/dist/workflow/graph-v2.js +391 -0
- package/dist/workflow/job-reconcile-v2.js +19 -0
- package/dist/workflow/lifecycle.js +82 -4
- package/dist/workflow/planner-orchestration-v2.js +58 -0
- package/dist/workflow/planner-trigger-v2.js +24 -0
- package/dist/workflow/review-v2.js +42 -0
- package/dist/workflow/runtime.js +15 -1
- package/dist/workflow/schedule-cycle-v2.js +25 -0
- package/dist/workflow/scheduled-run-v2.js +30 -0
- package/dist/workflow/task-action-policy.js +19 -0
- package/dist/workflow/task-reconcile.js +6 -6
- package/dist/workflow/task-related-action-v2.js +42 -0
- package/dist/workflow/troubleshooter-v2.js +29 -0
- package/dist/workspace.js +6 -1
- package/package.json +1 -1
- package/skills/agents-relay/agents/planner.agent.md +5 -5
- package/skills/agents-relay/contracts/task-job-contract.md +2 -51
- package/workflow/authoritative-unblock-v2.mmd +15 -0
- package/workflow/blocker-resolution-v2.mmd +73 -0
- package/workflow/job-lifecycle-v2.mmd +70 -0
- package/workflow/job-lifecycle.mmd +2 -2
- package/workflow/planner-orchestration-v2.mmd +65 -0
- package/workflow/planner-trigger-v2.mmd +32 -0
- package/workflow/review-lifecycle-v2.mmd +124 -0
- package/workflow/schedule-cycle-v2.mmd +19 -0
- package/workflow/scheduled-run-v2.mmd +55 -0
- package/workflow/task-lifecycle-v2.mmd +201 -0
- package/workflow/task-lifecycle.mmd +3 -0
- package/workflow/task-related-action-v2.mmd +28 -0
- package/workflow/troubleshooter-v2.mmd +43 -0
package/dist/runtime-status.js
CHANGED
|
@@ -2,7 +2,8 @@ function isSuperseded(task) {
|
|
|
2
2
|
return task.state === 'CANCELLED' && typeof task.error === 'string' && task.error.startsWith('Superseded ');
|
|
3
3
|
}
|
|
4
4
|
export function implementationTasks(job) {
|
|
5
|
-
return job.tasks.filter(task => task.
|
|
5
|
+
return job.tasks.filter(task => task.kind !== 'planner'
|
|
6
|
+
&& task.agentName !== 'reviewer'
|
|
6
7
|
&& task.agentName !== 'troubleshooter'
|
|
7
8
|
&& !isSuperseded(task)
|
|
8
9
|
&& task.state !== 'UNBLOCKED');
|
package/dist/scheduled-jobs.js
CHANGED
|
@@ -1,49 +1,58 @@
|
|
|
1
1
|
import { GitHubStore } from './store.js';
|
|
2
2
|
import { nextScheduleRun, scheduleBucket } from './scheduler.js';
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import { dispatchScheduledRun } from './workflow/scheduled-run-v2.js';
|
|
4
|
+
async function refresh(client, repository, trustedAuthors, ref, storeFactory) {
|
|
5
|
+
const store = storeFactory ? storeFactory() : new GitHubStore(client, repository, ref.prNumber, trustedAuthors);
|
|
5
6
|
return await store.load(ref.jobId);
|
|
6
7
|
}
|
|
7
|
-
export async function runScheduledBucket(client, repository, trustedAuthors, ref, engine, now = new Date()) {
|
|
8
|
-
let job = await refresh(client, repository, trustedAuthors, ref);
|
|
8
|
+
export async function runScheduledBucket(client, repository, trustedAuthors, ref, engine, now = new Date(), storeFactory) {
|
|
9
|
+
let job = await refresh(client, repository, trustedAuthors, ref, storeFactory);
|
|
9
10
|
const schedule = job.schedule;
|
|
10
11
|
if (!schedule)
|
|
11
|
-
throw new Error(
|
|
12
|
+
throw new Error('Scheduled job ' + job.id + ' has no schedule metadata');
|
|
12
13
|
const slot = new Date(schedule.nextRunAt);
|
|
13
|
-
if (scheduleBucket(slot) !== ref.bucket || slot.getTime() > now.getTime()) {
|
|
14
|
-
return { job, status: 'skipped-idempotent' };
|
|
15
|
-
}
|
|
16
|
-
if (schedule.lastScheduledSlot && new Date(schedule.lastScheduledSlot).getTime() >= slot.getTime()) {
|
|
17
|
-
return { job, status: 'skipped-idempotent' };
|
|
18
|
-
}
|
|
19
|
-
if (job.state !== 'ACTIVE')
|
|
20
|
-
return { job, status: 'skipped-idempotent' };
|
|
21
14
|
const active = job.tasks.some(task => ['QUEUED', 'RUNNING'].includes(task.state));
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
15
|
+
const schedulerStore = storeFactory ? storeFactory() : new GitHubStore(client, repository, ref.prNumber, trustedAuthors);
|
|
16
|
+
let actionStatus = null;
|
|
17
|
+
const decision = await dispatchScheduledRun({
|
|
18
|
+
slotDue: scheduleBucket(slot) === ref.bucket && slot.getTime() <= now.getTime(),
|
|
19
|
+
slotUnclaimed: !schedule.lastScheduledSlot || new Date(schedule.lastScheduledSlot).getTime() < slot.getTime(),
|
|
20
|
+
jobActive: job.state === 'ACTIVE', hasActiveTasks: active,
|
|
21
|
+
concurrencySkip: schedule.concurrencyPolicy === 'skip', concurrencyQueue: schedule.concurrencyPolicy === 'queue',
|
|
22
|
+
runMissed: schedule.missedRunPolicy === 'run',
|
|
23
|
+
}, async (actionId) => {
|
|
24
|
+
if (actionId === 'schedule.skipActiveAdvance') {
|
|
25
|
+
schedule.lastScheduledSlot = slot.toISOString();
|
|
26
|
+
schedule.nextRunAt = nextScheduleRun(schedule, now).toISOString();
|
|
27
|
+
job.updatedAt = now.toISOString();
|
|
28
|
+
await schedulerStore.saveJob(job);
|
|
29
|
+
actionStatus = 'skipped-active';
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
if (actionId === 'schedule.claimFromSlot' || actionId === 'schedule.claimFromNow') {
|
|
33
|
+
schedule.lastScheduledSlot = slot.toISOString();
|
|
34
|
+
schedule.currentRunSlot = slot.toISOString();
|
|
35
|
+
schedule.nextRunAt = nextScheduleRun(schedule, actionId === 'schedule.claimFromSlot' ? slot : now).toISOString();
|
|
36
|
+
job.updatedAt = now.toISOString();
|
|
37
|
+
await schedulerStore.saveJob(job);
|
|
38
|
+
job = await schedulerStore.load(job.id);
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
throw new Error('Unsupported schedule graph action ' + actionId);
|
|
42
|
+
});
|
|
43
|
+
if (!decision.accepted || decision.target === 'skip_idempotent')
|
|
44
|
+
return { job, status: 'skipped-idempotent' };
|
|
45
|
+
if (actionStatus === 'skipped-active' || decision.target === 'skip_active')
|
|
30
46
|
return { job, status: 'skipped-active' };
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
schedule.lastScheduledSlot = slot.toISOString();
|
|
34
|
-
schedule.currentRunSlot = slot.toISOString();
|
|
35
|
-
schedule.nextRunAt = nextScheduleRun(schedule, missedRunPolicy === 'run' ? slot : now).toISOString();
|
|
36
|
-
job.updatedAt = now.toISOString();
|
|
37
|
-
const schedulerStore = new GitHubStore(client, repository, ref.prNumber, trustedAuthors);
|
|
38
|
-
await schedulerStore.saveJob(job);
|
|
39
|
-
job = await schedulerStore.load(job.id);
|
|
47
|
+
if (decision.target !== 'claimed')
|
|
48
|
+
return { job, status: 'skipped-idempotent' };
|
|
40
49
|
if (!engine)
|
|
41
50
|
throw new Error('Scheduled jobs require the local relayd scheduler engine');
|
|
42
51
|
await engine.reconcile(job.id);
|
|
43
52
|
await engine.idle();
|
|
44
53
|
const finished = await schedulerStore.load(job.id);
|
|
45
54
|
if (!finished.schedule)
|
|
46
|
-
throw new Error(
|
|
55
|
+
throw new Error('Scheduled job ' + job.id + ' lost its schedule metadata');
|
|
47
56
|
finished.schedule.lastRunAt = now.toISOString();
|
|
48
57
|
finished.updatedAt = now.toISOString();
|
|
49
58
|
await schedulerStore.saveJob(finished);
|
package/dist/scheduler.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { implementationTasks } from './runtime-status.js';
|
|
2
|
-
import {
|
|
2
|
+
import { dispatchJobLifecycle, dispatchTaskLifecycle, dispatchTaskLifecycleSync } from './workflow/lifecycle.js';
|
|
3
3
|
import { reconcileTaskDependencies } from './workflow/task-reconcile.js';
|
|
4
4
|
export { dependencyReady, detectCycles } from './workflow/task-reconcile.js';
|
|
5
|
-
import {
|
|
5
|
+
import { dispatchScheduleCycle } from './workflow/schedule-cycle-v2.js';
|
|
6
6
|
export function completionGateSatisfied(job) {
|
|
7
7
|
if (job.schedule)
|
|
8
8
|
return false;
|
|
9
9
|
if (job.executionMode !== 'autonomous')
|
|
10
10
|
return true;
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
const planSatisfied = (job.plans ?? []).some(plan => plan.status === 'SUCCEEDED' && plan.objectiveStatus === 'satisfied');
|
|
12
|
+
const legacySatisfied = (job.plans?.length ?? 0) === 0 && job.tasks.some(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult?.objective_status === 'satisfied');
|
|
13
|
+
return (planSatisfied || legacySatisfied) && implementationTasks(job).every(task => task.state === 'SUCCEEDED');
|
|
14
14
|
}
|
|
15
15
|
export function schedule(job) {
|
|
16
16
|
reconcileTaskDependencies(job);
|
|
@@ -18,16 +18,21 @@ export function schedule(job) {
|
|
|
18
18
|
? job.tasks.filter(task => Date.parse(task.createdAt) >= Date.parse(job.schedule?.currentRunSlot ?? ''))
|
|
19
19
|
: job.tasks;
|
|
20
20
|
if (job.schedule) {
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
21
|
+
const slot = Date.parse(job.schedule.currentRunSlot ?? '');
|
|
22
|
+
const scopedPlans = (job.plans ?? []).filter(plan => !Number.isFinite(slot) || Date.parse(plan.createdAt) >= slot).sort((a, b) => a.round - b.round || a.createdAt.localeCompare(b.createdAt));
|
|
23
|
+
const latestPlan = scopedPlans.at(-1);
|
|
24
|
+
const legacyLatest = scopedPlans.length === 0 ? scopedTasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)).at(-1) : undefined;
|
|
25
|
+
dispatchScheduleCycle({
|
|
26
|
+
hasCurrentSlot: Boolean(job.schedule.currentRunSlot),
|
|
27
|
+
plannerSatisfied: latestPlan ? latestPlan.status === 'SUCCEEDED' && latestPlan.objectiveStatus === 'satisfied' : legacyLatest?.state === 'SUCCEEDED' && legacyLatest.plannerResult?.objective_status === 'satisfied',
|
|
28
|
+
}, () => {
|
|
24
29
|
const slot = job.schedule.currentRunSlot ? Date.parse(job.schedule.currentRunSlot) : Number.NaN;
|
|
25
30
|
const lastRun = job.schedule.lastRunAt ? Date.parse(job.schedule.lastRunAt) : Number.NaN;
|
|
26
31
|
if (Number.isFinite(slot) && (!Number.isFinite(lastRun) || lastRun < slot))
|
|
27
32
|
job.schedule.lastRunAt = new Date().toISOString();
|
|
28
33
|
job.schedule.currentRunSlot = null;
|
|
29
34
|
job.updatedAt = new Date().toISOString();
|
|
30
|
-
}
|
|
35
|
+
});
|
|
31
36
|
}
|
|
32
37
|
return job;
|
|
33
38
|
}
|
|
@@ -38,27 +43,23 @@ export function extendRetryBudget(task, windowSize = 3) {
|
|
|
38
43
|
return Math.max(windowSize, (Math.floor(usedOrBudgeted / windowSize) + 1) * windowSize);
|
|
39
44
|
}
|
|
40
45
|
export function tryTransitionTaskEvent(task, event, context = {}) {
|
|
41
|
-
|
|
42
|
-
if (decision.accepted && decision.target)
|
|
43
|
-
applyTaskWorkflowDecision(task, decision);
|
|
44
|
-
return decision;
|
|
46
|
+
return dispatchTaskLifecycleSync(task, event, context);
|
|
45
47
|
}
|
|
46
|
-
export async function transitionTaskEvent(task, event, context = {}) {
|
|
48
|
+
export async function transitionTaskEvent(task, event, context = {}, executeEffect, persist) {
|
|
47
49
|
const from = task.state;
|
|
48
|
-
const decision =
|
|
50
|
+
const decision = await dispatchTaskLifecycle(task, event, context, executeEffect, persist);
|
|
49
51
|
if (!decision.accepted || !decision.target)
|
|
50
52
|
throw new Error(decision.reason === 'ambiguous'
|
|
51
53
|
? `Ambiguous workflow transition from ${from} on ${event}`
|
|
52
54
|
: `No enabled workflow transition from ${from} on ${event}`);
|
|
53
55
|
return decision;
|
|
54
56
|
}
|
|
55
|
-
export async function transitionJobEvent(job, event, context = {}) {
|
|
56
|
-
const decision =
|
|
57
|
+
export async function transitionJobEvent(job, event, context = {}, executeEffect, persist) {
|
|
58
|
+
const decision = await dispatchJobLifecycle(job, event, context, executeEffect, persist);
|
|
57
59
|
if (!decision.accepted || !decision.target)
|
|
58
60
|
throw new Error(decision.reason === 'ambiguous'
|
|
59
61
|
? `Ambiguous workflow transition from ${job.state} on ${event}`
|
|
60
62
|
: `No enabled workflow transition from ${job.state} on ${event}`);
|
|
61
|
-
applyJobWorkflowDecision(job, decision);
|
|
62
63
|
return decision;
|
|
63
64
|
}
|
|
64
65
|
export const SCHEDULE_RESOLUTION_MS = 600_000;
|
package/dist/store.js
CHANGED
|
@@ -126,7 +126,6 @@ export class InMemoryStore {
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
export async function withGitHubRequestContext(client, context, work) { return client.withRequestContext ? client.withRequestContext(context, work) : work(); }
|
|
129
|
-
export { createGitHubAppJwt, GhApiClient } from './github-client.js';
|
|
130
129
|
async function githubGraphql(client, query, variables) {
|
|
131
130
|
if (!client.graphql)
|
|
132
131
|
throw new Error('GitHub GraphQL client is required');
|
|
@@ -257,6 +256,7 @@ export async function resolvePullRequest(client, repository, options) {
|
|
|
257
256
|
throw error;
|
|
258
257
|
}
|
|
259
258
|
}
|
|
259
|
+
export { GhApiClient, createGitHubAppJwt } from './github-client.js';
|
|
260
260
|
export { GitHubStore } from './store-github.js';
|
|
261
261
|
export async function listManagedGitHubJobs(client, repository, trustedAuthors) {
|
|
262
262
|
if (!client.graphql)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { extendRetryBudget, transitionTaskEvent, tryTransitionTaskEvent } from './scheduler.js';
|
|
2
|
+
import { selectRelatedTaskMutations } from './workflow/task-action-policy.js';
|
|
3
|
+
export const TASK_RETRY_ACTION = 'task.retry';
|
|
4
|
+
export const TASK_UNBLOCK_RETRY_ACTION = 'task.unblock.retry';
|
|
5
|
+
export const TASK_CANCEL_ACTION = 'task.cancel';
|
|
6
|
+
export const TASK_RELEASE_ACTION = 'task.release';
|
|
7
|
+
export const TASK_HOLD_ACTION = 'task.hold';
|
|
8
|
+
export const TASK_COMPLETE_ACTION = 'task.complete';
|
|
9
|
+
export const TASK_QUEUE_ACTION = 'task.queue';
|
|
10
|
+
function lifecycleRejection(task, action) {
|
|
11
|
+
return new Error(`Task ${task.id} is not ${action} (${task.state})`);
|
|
12
|
+
}
|
|
13
|
+
async function transitionTaskAction(task, event, action, facts = {}) {
|
|
14
|
+
try {
|
|
15
|
+
await transitionTaskEvent(task, event, facts);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error instanceof Error && error.message.startsWith('No enabled workflow transition')) {
|
|
19
|
+
throw lifecycleRejection(task, action);
|
|
20
|
+
}
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function clearExecution(task) {
|
|
25
|
+
task.error = null;
|
|
26
|
+
task.leaseOwner = null;
|
|
27
|
+
task.leaseExpiresAt = null;
|
|
28
|
+
task.executionId = null;
|
|
29
|
+
}
|
|
30
|
+
/** Shared task.retry mutation. The canonical lifecycle graph is the sole legality authority. */
|
|
31
|
+
export async function applyTaskRetryAction(task, policy = {}) {
|
|
32
|
+
if (task.kind === 'planner')
|
|
33
|
+
throw new Error(`Legacy planner Task ${task.id} is read-only; retry the Plan instead`);
|
|
34
|
+
await transitionTaskAction(task, 'retry', 'restartable');
|
|
35
|
+
if (policy.extendBudget)
|
|
36
|
+
task.maxAttempts = extendRetryBudget(task);
|
|
37
|
+
clearExecution(task);
|
|
38
|
+
return task;
|
|
39
|
+
}
|
|
40
|
+
/** Fixed-job authoritative unblock continuation: legality is owned by the lifecycle graph. */
|
|
41
|
+
export async function applyTaskUnblockRetryAction(task) {
|
|
42
|
+
await transitionTaskAction(task, 'retry', 'retryable after unblock');
|
|
43
|
+
task.maxAttempts = extendRetryBudget(task);
|
|
44
|
+
clearExecution(task);
|
|
45
|
+
delete task.blockedBy;
|
|
46
|
+
return task;
|
|
47
|
+
}
|
|
48
|
+
export function tryApplyTaskCancelAction(task, reason) {
|
|
49
|
+
if (task.kind === 'planner')
|
|
50
|
+
return false;
|
|
51
|
+
const decision = tryTransitionTaskEvent(task, 'cancel');
|
|
52
|
+
if (!decision.accepted)
|
|
53
|
+
return false;
|
|
54
|
+
task.error = reason;
|
|
55
|
+
task.leaseOwner = null;
|
|
56
|
+
task.leaseExpiresAt = null;
|
|
57
|
+
task.executionId = null;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
export async function applyTaskCancelAction(task, reason) {
|
|
61
|
+
if (task.kind === 'planner')
|
|
62
|
+
throw new Error(`Legacy planner Task ${task.id} is read-only`);
|
|
63
|
+
if (!tryApplyTaskCancelAction(task, reason))
|
|
64
|
+
throw lifecycleRejection(task, 'cancellable');
|
|
65
|
+
return task;
|
|
66
|
+
}
|
|
67
|
+
export async function applyTaskReleaseAction(task) {
|
|
68
|
+
await transitionTaskAction(task, 'release', 'releasable');
|
|
69
|
+
return task;
|
|
70
|
+
}
|
|
71
|
+
export async function applyTaskHoldAction(task) {
|
|
72
|
+
await transitionTaskAction(task, 'hold', 'holdable');
|
|
73
|
+
return task;
|
|
74
|
+
}
|
|
75
|
+
export async function applyTaskQueueAction(job, task) {
|
|
76
|
+
const taskMap = new Map(job.tasks.map(item => [item.id, item]));
|
|
77
|
+
const dependenciesSatisfied = task.dependencies.every(id => taskMap.get(id)?.state === 'SUCCEEDED');
|
|
78
|
+
await transitionTaskAction(task, 'queue', 'queueable', { dependenciesSatisfied });
|
|
79
|
+
clearExecution(task);
|
|
80
|
+
return task;
|
|
81
|
+
}
|
|
82
|
+
export async function applyOrchestratorCompleteAction(task, summary) {
|
|
83
|
+
if (task.adapter !== 'orchestrator')
|
|
84
|
+
throw new Error('task complete is only valid for adapter=orchestrator self-tasks');
|
|
85
|
+
const originalState = task.state;
|
|
86
|
+
tryTransitionTaskEvent(task, 'task.started');
|
|
87
|
+
task.attempt = Math.max(task.attempt, 1);
|
|
88
|
+
task.result = { summary };
|
|
89
|
+
clearExecution(task);
|
|
90
|
+
try {
|
|
91
|
+
await transitionTaskEvent(task, 'task.completed');
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error instanceof Error && error.message.startsWith('No enabled workflow transition')) {
|
|
95
|
+
throw new Error(`Task ${task.id} is ${originalState}; only QUEUED or RUNNING orchestrator tasks can be completed`);
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
return task;
|
|
100
|
+
}
|
|
101
|
+
async function applySelectedRelatedTaskMutations(job, mutations) {
|
|
102
|
+
const changed = [];
|
|
103
|
+
for (const mutation of mutations) {
|
|
104
|
+
const related = job.tasks.find(item => item.id === mutation.taskId);
|
|
105
|
+
if (!related)
|
|
106
|
+
throw new Error(`Related task ${mutation.taskId} not found`);
|
|
107
|
+
if (mutation.mutation === 'cancel')
|
|
108
|
+
await applyTaskCancelAction(related, mutation.reason);
|
|
109
|
+
changed.push(related);
|
|
110
|
+
}
|
|
111
|
+
return changed;
|
|
112
|
+
}
|
|
113
|
+
/** Compatibility bridge: domain mutation only; adapters own wake/reconcile/output behavior. */
|
|
114
|
+
export async function dispatchTaskAction(store, request) {
|
|
115
|
+
const job = await store.load(request.jobId);
|
|
116
|
+
const task = job.tasks.find(item => item.id === request.taskId);
|
|
117
|
+
if (!task)
|
|
118
|
+
throw new Error(`Task ${request.taskId} not found`);
|
|
119
|
+
const relatedMutations = selectRelatedTaskMutations(job, task, request.id);
|
|
120
|
+
if (request.id === TASK_RETRY_ACTION)
|
|
121
|
+
await applyTaskRetryAction(task, { extendBudget: true });
|
|
122
|
+
else if (request.id === TASK_UNBLOCK_RETRY_ACTION)
|
|
123
|
+
await applyTaskUnblockRetryAction(task);
|
|
124
|
+
else if (request.id === TASK_CANCEL_ACTION)
|
|
125
|
+
await applyTaskCancelAction(task, 'Cancelled by user');
|
|
126
|
+
else if (request.id === TASK_RELEASE_ACTION)
|
|
127
|
+
await applyTaskReleaseAction(task);
|
|
128
|
+
else if (request.id === TASK_HOLD_ACTION)
|
|
129
|
+
await applyTaskHoldAction(task);
|
|
130
|
+
else if (request.id === TASK_QUEUE_ACTION)
|
|
131
|
+
await applyTaskQueueAction(job, task);
|
|
132
|
+
else
|
|
133
|
+
throw new Error(`Unsupported task action ${request.id}`);
|
|
134
|
+
const changedRelated = await applySelectedRelatedTaskMutations(job, relatedMutations);
|
|
135
|
+
await store.saveTask(task);
|
|
136
|
+
for (const related of changedRelated)
|
|
137
|
+
await store.saveTask(related);
|
|
138
|
+
return { job, task };
|
|
139
|
+
}
|
package/dist/troubleshooter.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { taskOverallTimeoutMs } from './types.js';
|
|
2
|
+
import { dispatchTroubleshooter } from './workflow/troubleshooter-v2.js';
|
|
3
|
+
import { createTaskEntity } from './workflow/entity-creation.js';
|
|
2
4
|
function slug(value) {
|
|
3
5
|
return value.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 72) || 'task';
|
|
4
6
|
}
|
|
@@ -46,15 +48,20 @@ export function pendingTroubleshooterTasks(job) {
|
|
|
46
48
|
const existing = new Set(job.tasks.filter(task => task.agentName === 'troubleshooter').map(task => task.id));
|
|
47
49
|
const recoveries = [];
|
|
48
50
|
for (const task of job.tasks) {
|
|
49
|
-
if (!['FAILED', 'BLOCKED'].includes(task.state) || task.kind === 'planner' || task.agentName === 'troubleshooter' || !belongsToCurrentRun(job, task))
|
|
50
|
-
continue;
|
|
51
|
-
if (task.state === 'BLOCKED' && task.unblock)
|
|
52
|
-
continue;
|
|
53
51
|
const id = troubleshooterTaskId(task);
|
|
54
|
-
|
|
52
|
+
let create = false;
|
|
53
|
+
const decision = dispatchTroubleshooter({
|
|
54
|
+
failedOrBlocked: ['FAILED', 'BLOCKED'].includes(task.state),
|
|
55
|
+
ordinaryWork: task.kind !== 'planner' && task.agentName !== 'troubleshooter',
|
|
56
|
+
currentRun: belongsToCurrentRun(job, task),
|
|
57
|
+
needsRecovery: !(task.state === 'BLOCKED' && Boolean(task.unblock)),
|
|
58
|
+
idUnused: !existing.has(id),
|
|
59
|
+
noRecoveryBarrier: !hasRecoveryBarrier(job, task),
|
|
60
|
+
}, () => { create = true; });
|
|
61
|
+
if (!decision.accepted || !create)
|
|
55
62
|
continue;
|
|
56
63
|
const now = new Date().toISOString();
|
|
57
|
-
recoveries.push({
|
|
64
|
+
recoveries.push(createTaskEntity(job, {
|
|
58
65
|
jobId: job.id,
|
|
59
66
|
id,
|
|
60
67
|
kind: 'work',
|
|
@@ -71,7 +78,6 @@ export function pendingTroubleshooterTasks(job) {
|
|
|
71
78
|
output: { kind: 'task_pr' },
|
|
72
79
|
continuation: null,
|
|
73
80
|
continuationDeliveredAt: null,
|
|
74
|
-
state: 'QUEUED',
|
|
75
81
|
attempt: 0,
|
|
76
82
|
maxAttempts: 1,
|
|
77
83
|
leaseOwner: null,
|
|
@@ -84,7 +90,7 @@ export function pendingTroubleshooterTasks(job) {
|
|
|
84
90
|
timeoutMs: taskOverallTimeoutMs('M'),
|
|
85
91
|
createdAt: now,
|
|
86
92
|
updatedAt: now,
|
|
87
|
-
});
|
|
93
|
+
}));
|
|
88
94
|
}
|
|
89
95
|
return recoveries;
|
|
90
96
|
}
|
package/dist/types.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { assertV2StateTransition, jobLifecycleV2, taskLifecycleV2 } from './workflow/definitions-v2.js';
|
|
2
2
|
export const STORY_SIZE_TIMEOUT_MS = { S: 300000, M: 900000, L: 2700000 };
|
|
3
3
|
export function storySizeTimeoutMs(size) { return STORY_SIZE_TIMEOUT_MS[size]; }
|
|
4
4
|
export function taskOverallTimeoutMs(size, reasoning) { return storySizeTimeoutMs(size) * (reasoning?.toLowerCase() === 'high' ? 2 : 1); }
|
|
5
5
|
export function chatGptStartAckTimeoutMs(overallTimeoutMs) { return Math.min(600000, Math.max(120000, Math.floor(overallTimeoutMs / 3))); }
|
|
6
|
-
export function assertJobLifecycleTransition(from, to) {
|
|
7
|
-
export function assertTaskTransition(from, to) {
|
|
6
|
+
export function assertJobLifecycleTransition(from, to) { assertV2StateTransition(jobLifecycleV2.graph, from, to, 'job'); }
|
|
7
|
+
export function assertTaskTransition(from, to) { assertV2StateTransition(taskLifecycleV2.graph, from, to, 'task'); }
|
package/dist/unblock.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { transitionTaskEvent } from './scheduler.js';
|
|
2
|
+
import { applyTaskUnblockRetryAction } from './task-actions.js';
|
|
3
|
+
import { dispatchAuthoritativeUnblock } from './workflow/authoritative-unblock-v2.js';
|
|
2
4
|
export function isEffectivelyBlocked(task) { return task.kind !== 'planner' && task.state === 'BLOCKED'; }
|
|
3
5
|
function baselineFor(task) {
|
|
4
6
|
return task.blockedBaseline ?? { blockedAt: task.updatedAt, reason: task.error ?? undefined };
|
|
@@ -69,24 +71,32 @@ export async function unblockAutonomousTasks(options) {
|
|
|
69
71
|
export async function unblockTask(options) {
|
|
70
72
|
if (!isEffectivelyBlocked(options.task))
|
|
71
73
|
throw new Error(`Task ${options.task.id} is not an effective BLOCKED work task`);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
74
|
+
let result;
|
|
75
|
+
const decision = await dispatchAuthoritativeUnblock(options.job.executionMode === 'autonomous', async (actionId) => {
|
|
76
|
+
if (actionId === 'unblock.replan') {
|
|
77
|
+
result = { replanning: true, planTrigger: await unblockAutonomousTasks({ store: options.store, job: options.job, tasks: [options.task], trigger: options.trigger, now: options.now }) };
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
if (actionId === 'unblock.retry') {
|
|
81
|
+
const now = options.now ?? new Date().toISOString();
|
|
82
|
+
const currentHeadSha = await options.store.headSha?.() ?? options.job.headSha ?? null;
|
|
83
|
+
const baseline = baselineFor(options.task);
|
|
84
|
+
options.task.blockedBaseline = baseline;
|
|
85
|
+
const facts = unblockFacts(options.job, [options.task], currentHeadSha, options.trigger.reason);
|
|
86
|
+
options.task.unblock = { at: now, source: options.trigger.source, reason: options.trigger.reason, baseline, currentHeadSha: currentHeadSha ?? undefined, facts, blockerJobId: options.trigger.blockerJobId, resolution: options.trigger.resolution };
|
|
87
|
+
await transitionTaskEvent(options.task, 'task.unblocked');
|
|
88
|
+
await options.store.saveTask(options.task);
|
|
89
|
+
await applyTaskUnblockRetryAction(options.task);
|
|
90
|
+
options.task.updatedAt = now;
|
|
91
|
+
await options.store.saveTask(options.task);
|
|
92
|
+
result = { replanning: false };
|
|
93
|
+
return {};
|
|
94
|
+
}
|
|
95
|
+
throw new Error('Unsupported authoritative unblock action ' + actionId);
|
|
96
|
+
});
|
|
97
|
+
if (!decision.accepted)
|
|
98
|
+
throw new Error('Authoritative unblock graph rejected task.authoritative_unblock');
|
|
99
|
+
if (!result)
|
|
100
|
+
throw new Error('Authoritative unblock graph completed without an action result');
|
|
101
|
+
return result;
|
|
92
102
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { GraphRuntime } from './graph-v2.js';
|
|
3
|
+
import { dispatchWorkflowGraph, dispatchWorkflowGraphSync } from './dispatcher.js';
|
|
4
|
+
const source = readFileSync(new URL('../../workflow/authoritative-unblock-v2.mmd', import.meta.url), 'utf8');
|
|
5
|
+
const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
|
|
6
|
+
export const authoritativeUnblockV2 = new GraphRuntime(source, {
|
|
7
|
+
states: new Set(['blocked', 'autonomous_replan', 'fixed_retry']),
|
|
8
|
+
guards: { autonomousJob: boolGuard('autonomousJob') },
|
|
9
|
+
actions: {
|
|
10
|
+
'unblock.replan': { id: 'unblock.replan' },
|
|
11
|
+
'unblock.retry': { id: 'unblock.retry' },
|
|
12
|
+
},
|
|
13
|
+
events: { 'task.authoritative_unblock': { id: 'task.authoritative_unblock' } },
|
|
14
|
+
});
|
|
15
|
+
export async function dispatchAuthoritativeUnblock(autonomousJob, executeAction) {
|
|
16
|
+
return dispatchWorkflowGraph(authoritativeUnblockV2, { initialState: 'blocked', input: { transport: 'event', id: 'task.authoritative_unblock', payload: {} }, durableFacts: { autonomousJob }, executeAction });
|
|
17
|
+
}
|
|
18
|
+
export function decideAuthoritativeUnblock(autonomousJob) {
|
|
19
|
+
return dispatchWorkflowGraphSync(authoritativeUnblockV2, { initialState: 'blocked', input: { transport: 'event', id: 'task.authoritative_unblock', payload: {} }, durableFacts: { autonomousJob } });
|
|
20
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { GraphRuntime } from './graph-v2.js';
|
|
3
|
+
import { dispatchWorkflowGraph, dispatchWorkflowGraphSync } from './dispatcher.js';
|
|
4
|
+
const source = readFileSync(new URL('../../workflow/blocker-resolution-v2.mmd', import.meta.url), 'utf8');
|
|
5
|
+
const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
|
|
6
|
+
export const blockerResolutionV2 = new GraphRuntime(source, {
|
|
7
|
+
states: new Set([
|
|
8
|
+
'waiting', 'check_target', 'check_autonomous', 'check_effective', 'check_already_unblocked',
|
|
9
|
+
'check_draft', 'check_blocked', 'check_failed', 'check_unblocked', 'wait', 'obsolete', 'resumed',
|
|
10
|
+
]),
|
|
11
|
+
guards: {
|
|
12
|
+
resolutionAllowed: boolGuard('resolutionAllowed'),
|
|
13
|
+
targetMatches: boolGuard('targetMatches'),
|
|
14
|
+
autonomousJob: boolGuard('autonomousJob'),
|
|
15
|
+
effectivelyBlocked: boolGuard('effectivelyBlocked'),
|
|
16
|
+
alreadyUnblocked: boolGuard('alreadyUnblocked'),
|
|
17
|
+
isDraft: boolGuard('isDraft'),
|
|
18
|
+
isBlocked: boolGuard('isBlocked'),
|
|
19
|
+
isFailed: boolGuard('isFailed'),
|
|
20
|
+
isUnblocked: boolGuard('isUnblocked'),
|
|
21
|
+
},
|
|
22
|
+
actions: Object.fromEntries([
|
|
23
|
+
'blocker.autonomousUnblock', 'blocker.acceptExistingUnblock', 'blocker.releaseDraft',
|
|
24
|
+
'blocker.unblockTask', 'blocker.retryFailed', 'blocker.retryUnblocked',
|
|
25
|
+
].map(id => [id, { id }])),
|
|
26
|
+
events: { 'blocker.resolved': { id: 'blocker.resolved' } },
|
|
27
|
+
});
|
|
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 });
|
|
30
|
+
}
|
|
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 });
|
|
33
|
+
}
|
|
@@ -6,11 +6,17 @@ export const WORKFLOW_EVENT_NAMES = [
|
|
|
6
6
|
'effect.cancelActiveTasks.failed',
|
|
7
7
|
'effect.launchTask.failed',
|
|
8
8
|
'effect.mergePullRequest.failed',
|
|
9
|
+
'execution.orphaned',
|
|
9
10
|
'fail',
|
|
10
11
|
'hold',
|
|
12
|
+
'lease.expired',
|
|
11
13
|
'implementation.completed',
|
|
12
14
|
'pr.closed',
|
|
13
15
|
'pr.merged',
|
|
16
|
+
'queue',
|
|
17
|
+
'plan.requested',
|
|
18
|
+
'planner.failed',
|
|
19
|
+
'planner.unavailable',
|
|
14
20
|
'reconcile',
|
|
15
21
|
'release',
|
|
16
22
|
'retry',
|
|
@@ -36,10 +42,25 @@ export const WORKFLOW_FACT_NAMES = [
|
|
|
36
42
|
'notMerged',
|
|
37
43
|
'autoMerge',
|
|
38
44
|
'recoverableExecution',
|
|
45
|
+
'retryAvailable',
|
|
46
|
+
'initialPlan',
|
|
47
|
+
'scheduledRun',
|
|
48
|
+
'hasUnblock',
|
|
49
|
+
'manualPlan',
|
|
50
|
+
'prOpen',
|
|
51
|
+
'reviewDisabled',
|
|
52
|
+
'implementationComplete',
|
|
53
|
+
'headAvailable',
|
|
54
|
+
'mergeCapable',
|
|
39
55
|
];
|
|
40
56
|
export const WORKFLOW_MUTATION_NAMES = [
|
|
41
57
|
'mutation.clearError',
|
|
42
58
|
'mutation.clearFailureReason',
|
|
59
|
+
'mutation.clearLease',
|
|
60
|
+
'mutation.clearExecution',
|
|
61
|
+
'mutation.markLeaseExpired',
|
|
62
|
+
'mutation.markStartupOrphanedExhausted',
|
|
63
|
+
'mutation.markPlannerUnavailable',
|
|
43
64
|
];
|
|
44
65
|
export const WORKFLOW_EFFECT_NAMES = [
|
|
45
66
|
'effect.launchTask',
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { GraphRuntime } from './graph-v2.js';
|
|
3
|
+
import { WORKFLOW_EFFECT_NAMES, WORKFLOW_EVENT_NAMES, WORKFLOW_MUTATION_NAMES, } from './contracts.js';
|
|
4
|
+
const TASK_STATES = ['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED', 'UNBLOCKED', 'SUCCEEDED', 'FAILED', 'CANCELLED'];
|
|
5
|
+
const JOB_STATES = ['DRAFT', 'ACTIVE', 'COMPLETED', 'CANCELLED'];
|
|
6
|
+
const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
|
|
7
|
+
function eventRegistry() {
|
|
8
|
+
return Object.fromEntries(WORKFLOW_EVENT_NAMES.map(id => [id, { id }]));
|
|
9
|
+
}
|
|
10
|
+
function actionRegistry() {
|
|
11
|
+
return Object.fromEntries([...WORKFLOW_MUTATION_NAMES, ...WORKFLOW_EFFECT_NAMES].map(id => [id, { id }]));
|
|
12
|
+
}
|
|
13
|
+
function load(name, states, guards) {
|
|
14
|
+
const url = new URL(`../../workflow/${name}-v2.mmd`, import.meta.url);
|
|
15
|
+
return new GraphRuntime(readFileSync(url, 'utf8'), {
|
|
16
|
+
states: new Set(states),
|
|
17
|
+
guards,
|
|
18
|
+
actions: actionRegistry(),
|
|
19
|
+
events: eventRegistry(),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export const taskLifecycleV2 = load('task-lifecycle', TASK_STATES, {
|
|
23
|
+
dependenciesSatisfied: boolGuard('dependenciesSatisfied'),
|
|
24
|
+
dependenciesUnsatisfied: boolGuard('dependenciesUnsatisfied'),
|
|
25
|
+
runnable: boolGuard('runnable'),
|
|
26
|
+
dispatchable: boolGuard('dispatchable'),
|
|
27
|
+
recoverableExecution: boolGuard('recoverableExecution'),
|
|
28
|
+
retryAvailable: boolGuard('retryAvailable'),
|
|
29
|
+
});
|
|
30
|
+
export const jobLifecycleV2 = load('job-lifecycle', JOB_STATES, {
|
|
31
|
+
notMerged: boolGuard('notMerged'),
|
|
32
|
+
autoMerge: boolGuard('autoMerge'),
|
|
33
|
+
prOpen: boolGuard('prOpen'),
|
|
34
|
+
reviewDisabled: boolGuard('reviewDisabled'),
|
|
35
|
+
implementationComplete: boolGuard('implementationComplete'),
|
|
36
|
+
headAvailable: boolGuard('headAvailable'),
|
|
37
|
+
mergeCapable: boolGuard('mergeCapable'),
|
|
38
|
+
});
|
|
39
|
+
function topologyTargets(graph, from) {
|
|
40
|
+
const source = [...graph.nodes.values()].find(node => node.kind === 'state' && node.semanticId === from);
|
|
41
|
+
if (!source)
|
|
42
|
+
throw new Error(`Unknown workflow state ${from}`);
|
|
43
|
+
const targets = new Set();
|
|
44
|
+
const visit = (nodeId, seen) => {
|
|
45
|
+
if (seen.has(nodeId))
|
|
46
|
+
return;
|
|
47
|
+
seen.add(nodeId);
|
|
48
|
+
const node = graph.nodes.get(nodeId);
|
|
49
|
+
if (!node)
|
|
50
|
+
return;
|
|
51
|
+
if (node.kind === 'state') {
|
|
52
|
+
targets.add(node.semanticId);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
for (const edge of graph.edges) {
|
|
56
|
+
if (edge.from === nodeId && edge.kind !== 'emit')
|
|
57
|
+
visit(edge.to, new Set(seen));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
for (const edge of graph.edges) {
|
|
61
|
+
if (edge.from === source.id && (edge.kind === 'on' || edge.kind === 'request')) {
|
|
62
|
+
visit(edge.to, new Set([source.id]));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return targets;
|
|
66
|
+
}
|
|
67
|
+
export function assertV2StateTransition(graph, from, to, kind) {
|
|
68
|
+
if (from === to)
|
|
69
|
+
return;
|
|
70
|
+
const states = new Set([...graph.nodes.values()].filter(node => node.kind === 'state').map(node => node.semanticId));
|
|
71
|
+
if (!states.has(from) || !states.has(to))
|
|
72
|
+
throw new Error(`Unknown ${kind} state ${!states.has(from) ? from : to}`);
|
|
73
|
+
if (!topologyTargets(graph, from).has(to))
|
|
74
|
+
throw new Error(`Invalid ${kind} transition ${from} -> ${to}`);
|
|
75
|
+
}
|