agents-relay 2.0.106 → 2.0.108
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-help.js +10 -2
- package/dist/cli.js +15 -1
- package/dist/dashboard-html.js +3 -3
- package/dist/job-pause.js +75 -0
- package/dist/manual-plan.js +4 -0
- package/dist/planner-runtime.js +2 -0
- package/dist/pool.js +2 -0
- package/dist/reconciler-pause.js +11 -0
- package/dist/reconciler.js +12 -1
- package/dist/scheduler.js +2 -0
- package/dist/workflow/contracts.js +1 -0
- package/package.json +1 -1
- package/workflow/task-lifecycle.mmd +1 -0
package/dist/cli-help.js
CHANGED
|
@@ -8,7 +8,7 @@ Usage:
|
|
|
8
8
|
--no-github: SQLite/Graph-only mode. GitHub auth/API, PR creation, merge and fetch are disabled. Local completion is Graph-owned.
|
|
9
9
|
|
|
10
10
|
Commands:
|
|
11
|
-
job create|update|show|list|merge|unblock|cancel Manage SQLite-authoritative jobs with optional GitHub PRs
|
|
11
|
+
job create|update|show|list|merge|unblock|cancel|pause|resume Manage SQLite-authoritative jobs with optional GitHub PRs
|
|
12
12
|
task create|update|show|list|complete|retry|unblock|cancel Manage durable tasks
|
|
13
13
|
review request Request graph-owned review (optional thread override)
|
|
14
14
|
plan Invoke the Planner for an existing managed Job
|
|
@@ -29,7 +29,7 @@ Use 'node dist/cli.js' only for explicit local development/demo mode.`,
|
|
|
29
29
|
job: `Manage SQLite-authoritative durable jobs.
|
|
30
30
|
|
|
31
31
|
Usage:
|
|
32
|
-
npx agents-relay job <create|update|show|list|merge|unblock|cancel> [options]
|
|
32
|
+
npx agents-relay job <create|update|show|list|merge|unblock|cancel|pause|resume> [options]
|
|
33
33
|
|
|
34
34
|
Actions:
|
|
35
35
|
create Create a durable Job, optionally binding an existing unmanaged PR
|
|
@@ -39,6 +39,8 @@ Actions:
|
|
|
39
39
|
merge Queue a retryable external PR merge; Graph completes the Job only after confirmation
|
|
40
40
|
unblock Authoritatively invalidate current BLOCKED decisions and trigger replanning/resume
|
|
41
41
|
cancel Close the backing PR and cancel the Job
|
|
42
|
+
pause Persistently stop dispatch and interrupt active generations; lifecycle state remains unchanged
|
|
43
|
+
resume Clear pause metadata and requeue interrupted work
|
|
42
44
|
|
|
43
45
|
Common options:
|
|
44
46
|
--repo OWNER/REPO GitHub repository
|
|
@@ -94,6 +96,12 @@ Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. The current PR head S
|
|
|
94
96
|
'job cancel': `Cancel the durable Job and close its backing pull request.
|
|
95
97
|
|
|
96
98
|
Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. Nonterminal Tasks are cancelled before the PR is closed.`,
|
|
99
|
+
'job pause': `Pause a durable Job without inventing a PAUSED lifecycle state.
|
|
100
|
+
|
|
101
|
+
Required: --repo OWNER/REPO --id JOB_ID. Optional: --pr NUMBER. Running worker and Planner generations are interrupted durably and resume as uncompleted work.`,
|
|
102
|
+
'job resume': `Resume a paused durable Job.
|
|
103
|
+
|
|
104
|
+
Required: --repo OWNER/REPO --id JOB_ID. Optional: --pr NUMBER. Interrupted work is requeued and normal reconciliation continues.`,
|
|
97
105
|
escalation: `Run the canonical escalation workflow for a suspected human/external blocker.\n\nRequired: --intent TEXT|--intent-file PATH and --blocked-on TEXT|--blocked-on-file PATH. Optional: --context/--context-file, --on-needs-user/--on-needs-user-file, --cwd. The executable workflow/escalation.mmd graph invokes Neo escalation, then owns the resolved / failed / needs_user branch. Only needs_user selects the block-for-user path.`,
|
|
98
106
|
status: `Show live aggregate runtime status from the running Agents Relay service.
|
|
99
107
|
|
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 { pauseJob, resumeJob } from './job-pause.js';
|
|
44
45
|
export { ensureManagedGitHubJob } from './cli-job-create.js';
|
|
45
46
|
async function runManagedTaskCommand(action, args) {
|
|
46
47
|
const jobId = arg(args, '--id');
|
|
@@ -363,7 +364,7 @@ async function runContextCommand(action, args) {
|
|
|
363
364
|
console.log(JSON.stringify(context, null, 2));
|
|
364
365
|
}
|
|
365
366
|
async function runJobCommand(action, args) {
|
|
366
|
-
if (!['create', 'adopt', 'update', 'show', 'list', 'merge', 'unblock', 'cancel'].includes(action))
|
|
367
|
+
if (!['create', 'adopt', 'update', 'show', 'list', 'merge', 'unblock', 'cancel', 'pause', 'resume'].includes(action))
|
|
367
368
|
throw new Error(usage);
|
|
368
369
|
const repo = repository(args);
|
|
369
370
|
let createObjective;
|
|
@@ -400,6 +401,19 @@ async function runJobCommand(action, args) {
|
|
|
400
401
|
const id = arg(args, '--id');
|
|
401
402
|
if (!id && action !== 'create')
|
|
402
403
|
throw new Error('--id is required for managed local Jobs');
|
|
404
|
+
if (action === 'pause' || action === 'resume') {
|
|
405
|
+
const target = await storeFor(args);
|
|
406
|
+
if (target.job.repository !== repo)
|
|
407
|
+
throw new Error('Local Job/PR binding mismatch');
|
|
408
|
+
const prValue = arg(args, '--pr');
|
|
409
|
+
if (prValue && target.job.prNumber !== Number(prValue))
|
|
410
|
+
throw new Error('Local Job/PR binding mismatch');
|
|
411
|
+
const updated = action === 'pause' ? await pauseJob(target.store, id) : await resumeJob(target.store, id);
|
|
412
|
+
await saveLocal(target.localFile, updated);
|
|
413
|
+
await withEventBus(args, bus => publishWake(bus, updated, 'job', `Job ${id} ${action} requested`));
|
|
414
|
+
console.log(JSON.stringify(updated, null, 2));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
403
417
|
const prValue = arg(args, '--pr');
|
|
404
418
|
const prNumber = prValue ? Number(prValue) : 0;
|
|
405
419
|
if (prValue && (!Number.isInteger(prNumber) || (prNumber ?? 0) <= 0))
|
package/dist/dashboard-html.js
CHANGED
|
@@ -335,7 +335,7 @@ function field(label,input){const box=element('div','detail-group');box.append(e
|
|
|
335
335
|
function threadField(label,threadId){const box=element('div','detail-group');box.append(element('p','detail-label',text(label)));const id=String(threadId||'').trim();if(!/^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(id)){box.append(element('p','detail-value',text(id||'—')));return box;}const link=document.createElement('a');link.className='detail-value';link.href='https://chatgpt.com/c/'+encodeURIComponent(id);link.target='implementation';link.rel='noopener noreferrer';link.textContent=id.slice(0,8);link.title=id;box.append(link);return box;}function markdownField(label,input){const box=element('div','detail-group');box.append(element('p','detail-label',text(label)),markdownValue(input));return box;}
|
|
336
336
|
function rememberSelection(){if(selectedJob)sessionStorage.setItem('agents-relay:selected-job',selectedJob);else sessionStorage.removeItem('agents-relay:selected-job');if(selectedTaskId)sessionStorage.setItem('agents-relay:selected-task',selectedTaskId);else sessionStorage.removeItem('agents-relay:selected-task');}
|
|
337
337
|
function syncMobileJobsView(){const detail=mobileJobs.matches&&Boolean(selectedJob)&&location.hash.startsWith('#/jobs/');document.body.dataset.mobileJobsView=detail?'detail':'list';}
|
|
338
|
-
function shortManagedId(id,repository,kind){const parts=String(repository||'').split('/');if(parts.length!==2)return String(id||'');const prefix=kind+'-'+parts[0].length+'-'+parts[0]+'-'+parts[1]+'-';const value=String(id||'');const suffix=value.startsWith(prefix)?value.slice(prefix.length):'';return /^[0-9]+$/.test(suffix)?'#'+suffix:value;}function jobSelectionKey(job){return job?String(job.repository||'')+'|'+String(job.
|
|
338
|
+
function shortManagedId(id,repository,kind){const parts=String(repository||'').split('/');if(parts.length!==2)return String(id||'');const prefix=kind+'-'+parts[0].length+'-'+parts[0]+'-'+parts[1]+'-';const value=String(id||'');const suffix=value.startsWith(prefix)?value.slice(prefix.length):'';return /^[0-9]+$/.test(suffix)?'#'+suffix:value;}function jobSelectionKey(job){return job?String(job.repository||'')+'|'+String(job.id||''):'';}function dedupeJobs(entries){const byId=new Map();for(const job of entries){const key=jobSelectionKey(job);if(!key)continue;const existing=byId.get(key);if(!existing){byId.set(key,job);continue;}const fresh=isFreshEnough(job.updatedAt,existing.updatedAt);const preferred=fresh?job:existing;const other=fresh?existing:job;byId.set(key,{...other,...preferred,prNumber:Number(preferred.prNumber||0)>0?preferred.prNumber:other.prNumber});}return [...byId.values()];}
|
|
339
339
|
function jobForSelection(selection){return jobs.find(job=>jobSelectionKey(job)===selection)||jobs.find(job=>job.id===selection)||null;}
|
|
340
340
|
function selected(){return jobForSelection(selectedJob)||jobs[0]||null;}
|
|
341
341
|
function counts(tasks){const out={};for(const task of Array.isArray(tasks)?tasks:[])out[task.state]=(out[task.state]||0)+1;return out;}
|
|
@@ -421,7 +421,7 @@ function renderRateLimitStatus(status){
|
|
|
421
421
|
function runtimePill(label,value){const pill=element('span','runtime-stat');pill.append(document.createTextNode(label+' '),element('strong','',text(value)));return pill;}
|
|
422
422
|
async function drawRuntimeSummary(){try{const summary=await loadJSON('/api/runtime-summary');runtimeSummary.replaceChildren(runtimePill('Active Jobs',summary.activeJobs||0),runtimePill('Running Tasks',summary.runningTasks||0),runtimePill('ChatGPT',summary.workers?.chatgpt||0),runtimePill('Codex',summary.workers?.codex||0));for(const item of Array.isArray(summary.models)?summary.models:[])runtimeSummary.append(runtimePill(item.model,item.count));}catch{runtimeSummary.replaceChildren();}}
|
|
423
423
|
async function refreshStatus(){try{renderRateLimitStatus(await loadJSON('/api/status'));}catch{renderRateLimitStatus(null);}}
|
|
424
|
-
async function refresh(force=false){jobsRefresh.disabled=true;jobsRefresh.textContent='Refreshing…';try{const discovered=await loadJSON('/api/jobs'+(force?'?refresh=1':''));jobs=Array.isArray(discovered)?discovered:[];void drawRuntimeSummary();jobs.sort(compareJobs);if(selectedJob&&!jobForSelection(selectedJob)){selectedJob=null;selectedTaskId=null;}if(!selectedJob){const match=location.hash.match(/^#\\/jobs\\/(.+)$/);const fromHash=match?decodeURIComponent(match[1]):null;const fromRoute=jobForSelection(fromHash);selectedJob=jobSelectionKey(fromRoute||jobs[0]);}rememberSelection();renderAll();void loadEventHistory();connection.textContent='';connection.setAttribute('aria-label','Live durable state connected');connection.className='status-pill ok';}catch(error){connection.textContent='';connection.setAttribute('aria-label','Durable state unavailable');connection.className='status-pill bad';jobList.replaceChildren(element('li','error-state',text('Could not load jobs: '+error.message)));}finally{jobsRefresh.disabled=false;jobsRefresh.textContent='Refresh';}}
|
|
424
|
+
async function refresh(force=false){jobsRefresh.disabled=true;jobsRefresh.textContent='Refreshing…';try{const discovered=await loadJSON('/api/jobs'+(force?'?refresh=1':''));jobs=dedupeJobs(Array.isArray(discovered)?discovered:[]);void drawRuntimeSummary();jobs.sort(compareJobs);if(selectedJob&&!jobForSelection(selectedJob)){selectedJob=null;selectedTaskId=null;}if(!selectedJob){const match=location.hash.match(/^#\\/jobs\\/(.+)$/);const fromHash=match?decodeURIComponent(match[1]):null;const fromRoute=jobForSelection(fromHash);selectedJob=jobSelectionKey(fromRoute||jobs[0]);}rememberSelection();renderAll();void loadEventHistory();connection.textContent='';connection.setAttribute('aria-label','Live durable state connected');connection.className='status-pill ok';}catch(error){connection.textContent='';connection.setAttribute('aria-label','Durable state unavailable');connection.className='status-pill bad';jobList.replaceChildren(element('li','error-state',text('Could not load jobs: '+error.message)));}finally{jobsRefresh.disabled=false;jobsRefresh.textContent='Refresh';}}
|
|
425
425
|
function renderEventScope(){const task=eventTaskId&&selected()?.tasks?.find(candidate=>candidate.id===eventTaskId);eventsAllTasks.disabled=!task;eventsAllTasks.setAttribute('aria-pressed',String(!task));eventsScope.textContent=task?'Task '+task.id+' · newest first · 60 max':'Job-wide · newest first · 60 max';}
|
|
426
426
|
function eventLabel(type){return ({'task.process.launched':'Process launched','task.started':'Worker started','task.process.exited':'Sync process exited','task.process.async_exited':'Async process exited','task.completed':'Worker completed','task.failed':'Worker failed','task.blocked':'Worker blocked','review.started':'Review started','review.approved':'Review approved','review.changes_required':'Review changes required','review.blocked':'Review blocked'})[type]||type||'Event';}
|
|
427
427
|
function eventSeverity(event,level){const type=String(event?.type||'').toLowerCase(),eventLevel=String(event?.level||level||'').toLowerCase(),outcome=String(event?.outcome||event?.status||'').toLowerCase();if(eventLevel==='error'||outcome==='failed'||type.endsWith('.failed')||type==='task.failed')return 'error';if(eventLevel==='warn'||outcome==='blocked'||type.endsWith('.blocked')||type==='task.blocked'||type==='review.blocked')return 'warn';return 'normal';}
|
|
@@ -484,7 +484,7 @@ window.addEventListener('keydown',event=>{if(event.key==='Tab'&&event.altKey){ev
|
|
|
484
484
|
function parseMessage(message){try{return JSON.parse(message.data);}catch{return null;}}
|
|
485
485
|
function isFreshEnough(incoming,current){const a=Date.parse(incoming||''),b=Date.parse(current||'');return !Number.isFinite(b)||!Number.isFinite(a)||a>=b;}
|
|
486
486
|
function applyStatePatch(patch){if(!patch?.jobId)return false;const job=jobs.find(candidate=>candidate.id===patch.jobId);if(!job)return false;if(patch.jobState&&isFreshEnough(patch.jobUpdatedAt,job.updatedAt)){job['state']=patch.jobState;if(patch.jobUpdatedAt)job.updatedAt=patch.jobUpdatedAt;}if(patch.taskId&&patch.taskState){const task=(job.tasks||[]).find(candidate=>candidate.id===patch.taskId);if(task&&isFreshEnough(patch.taskUpdatedAt,task.updatedAt)){task['state']=patch.taskState;if(patch.taskUpdatedAt)task.updatedAt=patch.taskUpdatedAt;}}return true;}
|
|
487
|
-
function upsertFreshJob(incoming){if(!incoming)return;const index=jobs.findIndex(job=>jobSelectionKey(job)===jobSelectionKey(incoming));if(index<0){jobs.push(incoming);return;}if(isFreshEnough(incoming.updatedAt,jobs[index].updatedAt))jobs[index]=incoming;}
|
|
487
|
+
function upsertFreshJob(incoming){if(!incoming)return;const index=jobs.findIndex(job=>jobSelectionKey(job)===jobSelectionKey(incoming));if(index<0){jobs.push(incoming);return;}if(isFreshEnough(incoming.updatedAt,jobs[index].updatedAt))jobs[index]={...incoming,prNumber:Number(incoming.prNumber||0)>0?incoming.prNumber:jobs[index].prNumber};}
|
|
488
488
|
const stream=new EventSource('/events');
|
|
489
489
|
stream.onopen=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream connected');connection.className='status-pill ok';};
|
|
490
490
|
stream.onerror=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream reconnecting');connection.className='status-pill warn';};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { tryTransitionTaskEvent } from './scheduler.js';
|
|
2
|
+
export async function pauseJob(store, jobId) {
|
|
3
|
+
const mutate = (current) => {
|
|
4
|
+
if (['COMPLETED', 'CANCELLED'].includes(current.state))
|
|
5
|
+
throw new Error(`Cannot pause terminal Job ${current.id} (${current.state})`);
|
|
6
|
+
if (current.pause)
|
|
7
|
+
return current;
|
|
8
|
+
const now = new Date().toISOString();
|
|
9
|
+
current.pause = { generation: 1, requestedAt: now };
|
|
10
|
+
for (const task of current.tasks) {
|
|
11
|
+
if (task.kind === 'planner' || ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(task.state))
|
|
12
|
+
continue;
|
|
13
|
+
if (task.executionId)
|
|
14
|
+
task.pausedExecution = { id: task.executionId, state: task.state === 'RUNNING' ? 'RUNNING' : 'QUEUED', attempt: task.attempt, at: now };
|
|
15
|
+
if (task.state === 'RUNNING' && !tryTransitionTaskEvent(task, 'pause').accepted)
|
|
16
|
+
throw new Error(`Cannot pause running Task ${task.id}`);
|
|
17
|
+
task.executionId = null;
|
|
18
|
+
task.leaseOwner = null;
|
|
19
|
+
task.leaseExpiresAt = null;
|
|
20
|
+
task.updatedAt = now;
|
|
21
|
+
}
|
|
22
|
+
for (const plan of current.plans ?? []) {
|
|
23
|
+
if (plan.status !== 'PLANNING')
|
|
24
|
+
continue;
|
|
25
|
+
if (plan.executionId)
|
|
26
|
+
plan.pausedExecutionId = plan.executionId;
|
|
27
|
+
plan.executionId = null;
|
|
28
|
+
plan.leaseOwner = null;
|
|
29
|
+
plan.leaseExpiresAt = null;
|
|
30
|
+
plan.updatedAt = now;
|
|
31
|
+
}
|
|
32
|
+
current.updatedAt = now;
|
|
33
|
+
return current;
|
|
34
|
+
};
|
|
35
|
+
if (store.mutateJob)
|
|
36
|
+
return store.mutateJob(jobId, mutate);
|
|
37
|
+
const job = await store.load(jobId);
|
|
38
|
+
const updated = mutate(job);
|
|
39
|
+
await store.saveJob(updated);
|
|
40
|
+
for (const task of updated.tasks)
|
|
41
|
+
await store.saveTask(task);
|
|
42
|
+
for (const plan of updated.plans ?? [])
|
|
43
|
+
await store.savePlan(plan);
|
|
44
|
+
return updated;
|
|
45
|
+
}
|
|
46
|
+
export async function resumeJob(store, jobId) {
|
|
47
|
+
const mutate = (current) => {
|
|
48
|
+
if (!current.pause)
|
|
49
|
+
return current;
|
|
50
|
+
const now = new Date().toISOString();
|
|
51
|
+
delete current.pause;
|
|
52
|
+
for (const task of current.tasks) {
|
|
53
|
+
if (task.pausedExecution) {
|
|
54
|
+
if (task.attempt >= task.maxAttempts)
|
|
55
|
+
task.maxAttempts = task.attempt + 1;
|
|
56
|
+
delete task.pausedExecution;
|
|
57
|
+
task.updatedAt = now;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const plan of current.plans ?? [])
|
|
61
|
+
delete plan.pausedExecutionId;
|
|
62
|
+
current.updatedAt = now;
|
|
63
|
+
return current;
|
|
64
|
+
};
|
|
65
|
+
if (store.mutateJob)
|
|
66
|
+
return store.mutateJob(jobId, mutate);
|
|
67
|
+
const job = await store.load(jobId);
|
|
68
|
+
const updated = mutate(job);
|
|
69
|
+
await store.saveJob(updated);
|
|
70
|
+
for (const task of updated.tasks)
|
|
71
|
+
await store.saveTask(task);
|
|
72
|
+
for (const plan of updated.plans ?? [])
|
|
73
|
+
await store.savePlan(plan);
|
|
74
|
+
return updated;
|
|
75
|
+
}
|
package/dist/manual-plan.js
CHANGED
|
@@ -5,6 +5,8 @@ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launc
|
|
|
5
5
|
let job = await store.load(jobId);
|
|
6
6
|
if (['COMPLETED', 'CANCELLED'].includes(job.state))
|
|
7
7
|
throw new Error(`Cannot plan terminal job ${job.id} (${job.state})`);
|
|
8
|
+
if (job.pause)
|
|
9
|
+
throw new Error(`Cannot plan paused Job ${job.id}; resume it first`);
|
|
8
10
|
const active = pendingPlan(job);
|
|
9
11
|
if (active && active.executionId)
|
|
10
12
|
throw new Error(`Planning round ${active.id} is already PLANNING`);
|
|
@@ -13,6 +15,8 @@ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launc
|
|
|
13
15
|
const committed = await store.mutateJob(jobId, current => {
|
|
14
16
|
if (['COMPLETED', 'CANCELLED'].includes(current.state))
|
|
15
17
|
throw new Error(`Cannot plan terminal job ${current.id} (${current.state})`);
|
|
18
|
+
if (current.pause)
|
|
19
|
+
throw new Error(`Cannot plan paused Job ${current.id}; resume it first`);
|
|
16
20
|
const concurrent = pendingPlan(current);
|
|
17
21
|
if (concurrent) {
|
|
18
22
|
if (concurrent.executionId)
|
package/dist/planner-runtime.js
CHANGED
|
@@ -281,6 +281,8 @@ export async function launchPlanExecution(ports, job, plan) {
|
|
|
281
281
|
}
|
|
282
282
|
const claimed = await withExecutionBoundary(job.id, '__job_execution_slot__', async () => {
|
|
283
283
|
const freshJob = await ports.store.load(job.id);
|
|
284
|
+
if (freshJob.pause)
|
|
285
|
+
return null;
|
|
284
286
|
const fresh = (freshJob.plans ?? []).find(item => item.id === plan.id);
|
|
285
287
|
if (!fresh || fresh.status !== 'PLANNING')
|
|
286
288
|
return null;
|
package/dist/pool.js
CHANGED
|
@@ -10,6 +10,8 @@ const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
|
10
10
|
export function compareJobPriority(a, b) { return RANK[a.priority ?? 'P2'] - RANK[b.priority ?? 'P2'] || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
|
|
11
11
|
export function runnableManagedJobs(items) { return items.filter(item => item.job.state === 'ACTIVE').map(item => item.job).sort(compareJobPriority); }
|
|
12
12
|
export function needsLifecycleReconciliation(job, now = Date.now()) {
|
|
13
|
+
if (job.pause)
|
|
14
|
+
return job.tasks.some(task => Boolean(task.pausedExecution)) || Boolean(job.plans?.some(plan => Boolean(plan.pausedExecutionId)));
|
|
13
15
|
// DRAFT create-only Jobs stay dormant until a caller explicitly creates work.
|
|
14
16
|
if (job.tasks.length === 0)
|
|
15
17
|
return false;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Stop only generations already invalidated by the durable pause mutation. */
|
|
2
|
+
export function cancelPausedGenerations(job, cancelTask, cancelPlan) {
|
|
3
|
+
if (!job.pause)
|
|
4
|
+
return;
|
|
5
|
+
for (const task of job.tasks)
|
|
6
|
+
if (task.pausedExecution)
|
|
7
|
+
cancelTask(task.id, task.pausedExecution.id);
|
|
8
|
+
for (const plan of job.plans ?? [])
|
|
9
|
+
if (plan.pausedExecutionId)
|
|
10
|
+
cancelPlan(plan.id);
|
|
11
|
+
}
|
package/dist/reconciler.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { cancelPausedGenerations } from './reconciler-pause.js';
|
|
1
2
|
import { taskCommentContext } from './task-comments.js';
|
|
2
3
|
import { cancelActiveTasks } from './reconciler-cancellation.js';
|
|
3
4
|
import { authorizedInjection } from './e2e-injection.js';
|
|
@@ -41,7 +42,7 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
41
42
|
this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.launchTask', async (context) => {
|
|
42
43
|
const job = await this.store.load(context.jobId);
|
|
43
44
|
const task = context.taskId ? job.tasks.find(item => item.id === context.taskId) : undefined;
|
|
44
|
-
if (!task || task.state !== 'QUEUED' || task.executionId !== null)
|
|
45
|
+
if (!task || job.pause || task.state !== 'QUEUED' || task.executionId !== null)
|
|
45
46
|
return;
|
|
46
47
|
if (task.kind !== 'planner' && task.adapter !== 'orchestrator')
|
|
47
48
|
await this.launch(job, task);
|
|
@@ -51,6 +52,8 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
51
52
|
}
|
|
52
53
|
async dispatchExecutableTasks(jobId) {
|
|
53
54
|
const scheduled = schedule(await this.store.load(jobId));
|
|
55
|
+
if (scheduled.pause)
|
|
56
|
+
return scheduled;
|
|
54
57
|
for (const task of scheduled.tasks.filter(item => item.state === 'SUCCEEDED' && (item.continuation || scheduled.continuation) && !item.continuationDeliveredAt)) {
|
|
55
58
|
this.detach(this.deliverContinuation(scheduled, task), scheduled.id, task.id, 'continuation');
|
|
56
59
|
}
|
|
@@ -92,6 +95,14 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
92
95
|
}
|
|
93
96
|
await this.invalidateStaleReview(job, headSha);
|
|
94
97
|
const now = (this.options.now ?? new Date()).getTime();
|
|
98
|
+
if (job.pause) {
|
|
99
|
+
cancelPausedGenerations(job, (taskId, executionId) => this.cancelExecutionGeneration(job.id, taskId, executionId), planId => { const live = this.plannerLive.get(planId); if (live) {
|
|
100
|
+
clearTimeout(live.timer);
|
|
101
|
+
live.controller.abort();
|
|
102
|
+
this.plannerLive.delete(planId);
|
|
103
|
+
} });
|
|
104
|
+
return job;
|
|
105
|
+
}
|
|
95
106
|
if (job.state !== 'ACTIVE')
|
|
96
107
|
return job;
|
|
97
108
|
await recoverExpiredExecutions(job, now, this.executionRecoveryPorts(job));
|
package/dist/scheduler.js
CHANGED
|
@@ -17,6 +17,8 @@ export function completionGateSatisfied(job) {
|
|
|
17
17
|
return (planSatisfied || legacySatisfied) && implementationTasks(job).every(task => task.state === 'SUCCEEDED');
|
|
18
18
|
}
|
|
19
19
|
export function schedule(job) {
|
|
20
|
+
if (job.pause)
|
|
21
|
+
return job;
|
|
20
22
|
reconcileTaskDependencies(job);
|
|
21
23
|
const scopedTasks = job.schedule?.currentRunSlot
|
|
22
24
|
? job.tasks.filter(task => Date.parse(task.createdAt) >= Date.parse(job.schedule?.currentRunSlot ?? ''))
|
package/package.json
CHANGED
|
@@ -178,6 +178,7 @@ leaseFailRunningMark --> leaseFailRunningClearLease
|
|
|
178
178
|
leaseFailRunningClearLease --> leaseFailRunningClearExecution
|
|
179
179
|
leaseFailRunningClearExecution --> failed
|
|
180
180
|
running -->|on: task.cancelled| cancelled
|
|
181
|
+
running -->|on: pause| queued
|
|
181
182
|
running -->|on: cancel| cancelled
|
|
182
183
|
|
|
183
184
|
autonomousJob["guard: autonomousJob"]
|