@jc_stack/ez-agents 0.1.0-beta.49 → 0.1.0-beta.51
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/AGENTS.md +8 -6
- package/CHANGELOG.md +26 -0
- package/README.md +9 -5
- package/bin/ez +16 -7
- package/docs/architecture/ai-selection.md +4 -0
- package/docs/docker-runtime.md +28 -2
- package/docs/plugin-guide.md +3 -3
- package/docs/plugins.md +13 -10
- package/docs/scheduling.md +62 -22
- package/docs/setup.md +2 -2
- package/docs/standalone-cli.md +4 -4
- package/docs/upgrades.md +16 -15
- package/package.json +4 -2
- package/src/agent-guidance.ts +2 -2
- package/src/ai-cli.ts +4 -2
- package/src/ai.ts +17 -5
- package/src/application-channel.ts +12 -8
- package/src/control-state.ts +10 -4
- package/src/delivery-socket-client.mjs +15 -1
- package/src/delivery-socket.ts +18 -4
- package/src/executor.ts +63 -10
- package/src/host-executor-client.ts +23 -8
- package/src/host-executor.ts +63 -32
- package/src/index.ts +157 -35
- package/src/menu.ts +3 -2
- package/src/plugins/manager.mjs +43 -40
- package/src/plugins/workspace-lease.mjs +1 -1
- package/src/runs.ts +67 -3
- package/src/schedule-cli.ts +24 -15
- package/src/scheduled-tasks.ts +53 -17
- package/src/scheduler.ts +7 -8
- package/src/session-close.ts +17 -0
- package/src/setup-fallback.ts +62 -25
- package/src/tools-line.d.mts +1 -0
- package/src/tools-line.mjs +3 -0
- package/src/updates/binding.mjs +4 -10
- package/src/updates/control.mjs +3 -2
- package/src/updates/runtime.mjs +25 -9
- package/src/updates/supervisor.mjs +105 -16
- package/src/workspace.ts +12 -1
- package/test/ai-cli.test.ts +23 -1
- package/test/ai.test.ts +72 -4
- package/test/application-channel.test.ts +24 -0
- package/test/application-controls.test.ts +4 -1
- package/test/delivery-transport.test.ts +41 -2
- package/test/executor.test.ts +32 -4
- package/test/host-executor.test.ts +70 -3
- package/test/intake-relay.test.ts +71 -3
- package/test/model-policy.test.ts +1 -1
- package/test/plugin-broker.test.mjs +48 -1
- package/test/plugin-manager.test.mjs +52 -38
- package/test/run-handoff.test.ts +136 -0
- package/test/schedule-cli.test.ts +24 -1
- package/test/scheduled-tasks.test.ts +45 -15
- package/test/session-close.test.ts +17 -0
- package/test/setup-fallback.test.ts +194 -16
- package/test/updates.test.mjs +55 -12
- package/test/workspace.test.ts +6 -0
package/src/plugins/manager.mjs
CHANGED
|
@@ -7,7 +7,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { createHash, randomUUID, randomBytes } from 'node:crypto';
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
9
|
import { callDeliverySocket } from '../delivery-socket-client.mjs';
|
|
10
|
-
import {
|
|
10
|
+
import { TOOLS_LINE } from '../tools-line.mjs';
|
|
11
|
+
import { readFileSync, readlinkSync, realpathSync } from 'node:fs';
|
|
11
12
|
|
|
12
13
|
const reserved = new Set(['status','updates','plugins','tools','message','owner','approval','react','setup','help','version']);
|
|
13
14
|
const id = value => { if(typeof value !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(value)) throw Error('Invalid identifier'); return value; };
|
|
@@ -41,39 +42,31 @@ export async function stewardOwned(file) {
|
|
|
41
42
|
const parent = await fs.stat(path.dirname(file));
|
|
42
43
|
await fs.chown(file, parent.uid, parent.gid);
|
|
43
44
|
}
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if(config?.hostConfig) {
|
|
51
|
-
const host=await json(config.hostConfig);
|
|
52
|
-
if(host.isolation==='isolated') launcher='ez';
|
|
53
|
-
}
|
|
45
|
+
// Retire the former `ez tools` footer once: replace the marked block with
|
|
46
|
+
// TOOLS_LINE and leave every other byte untouched. The name is kept because
|
|
47
|
+
// upgraders released before this change call it on the target package;
|
|
48
|
+
// remove it when no installed core predates the change.
|
|
49
|
+
export async function bindToolDiscovery(_home,workspace) {
|
|
50
|
+
workspace=await fs.realpath(workspace);
|
|
54
51
|
const start='<!-- ez tools: begin -->',end='<!-- ez tools: end -->';
|
|
55
|
-
const block=start+'\nUse `'+launcher+'`; run it with `--help` or `tools list --details` to discover installed capabilities, and read the matching skill when needed.\nKeep the channel responsive: during a foreground owner turn, prefer `ezenciel-agents-schedule create --now ...` when work can continue independently. A `[schedule ...]` turn executes its assigned work directly and must not schedule it again.\n'+end;
|
|
56
|
-
if(Buffer.byteLength(block)>600)throw Error('Tool discovery guidance exceeds the 600-byte core budget');
|
|
57
|
-
return {block,start,end};
|
|
58
|
-
}
|
|
59
|
-
export async function bindToolDiscovery(home,workspace) {
|
|
60
|
-
home=await fs.realpath(home);workspace=await fs.realpath(workspace);
|
|
61
|
-
const {block,start,end}=await toolDiscoveryBlock(home);
|
|
62
52
|
for(const name of ['AGENTS.md','AGENTS.override.md']) {
|
|
63
53
|
const file=path.join(workspace,name);
|
|
64
54
|
const stat=await fs.lstat(file).catch(e=>{if(e.code==='ENOENT')return null;throw e;});
|
|
65
|
-
if(!stat
|
|
66
|
-
if(
|
|
67
|
-
const prior=
|
|
55
|
+
if(!stat)continue;
|
|
56
|
+
if(!stat.isFile())throw Error('Tool instructions must be a regular file');
|
|
57
|
+
const prior=await fs.readFile(file,'utf8');
|
|
68
58
|
const from=prior.indexOf(start),to=prior.indexOf(end);
|
|
69
|
-
if(
|
|
70
|
-
|
|
71
|
-
|
|
59
|
+
if(from<0&&to<0)continue;
|
|
60
|
+
if((from<0)!==(to<0)||to<from||prior.indexOf(start,from+start.length)>=0||prior.indexOf(end,to+end.length)>=0)throw Error('Malformed tool discovery block');
|
|
61
|
+
const next=prior.slice(0,from)+(prior.includes(TOOLS_LINE)?'':TOOLS_LINE)+prior.slice(to+end.length);
|
|
72
62
|
const tmp=file+'.'+randomUUID()+'.tmp';
|
|
73
63
|
try {await fs.writeFile(tmp,next,{mode:0o600,flag:'wx'});await fs.rename(tmp,file);}finally{await fs.rm(tmp,{force:true});}
|
|
74
64
|
}
|
|
75
65
|
}
|
|
76
66
|
const invocationDirectory = home => path.join(home,'command-invocations');
|
|
67
|
+
// A lease pid is only meaningful inside the PID namespace that wrote it. The
|
|
68
|
+
// isolated broker writes leases from its container, which the host cannot probe.
|
|
69
|
+
const pidNamespace=(()=>{try{return readlinkSync('/proc/self/ns/pid');}catch{return undefined;}})();
|
|
77
70
|
async function activeInvocations(home) {
|
|
78
71
|
const directory=invocationDirectory(home),files=await fs.readdir(directory).catch(error=>{if(error.code==='ENOENT')return [];throw error;});
|
|
79
72
|
for(const file of files) {
|
|
@@ -81,8 +74,11 @@ async function activeInvocations(home) {
|
|
|
81
74
|
const lease=await json(path.join(directory,file)).catch(error=>{if(error.code==='ENOENT')return null;throw error;});
|
|
82
75
|
if(!lease)continue;
|
|
83
76
|
if(!Number.isSafeInteger(lease.pid)||lease.pid<1||typeof lease.container!=='string'||!lease.container)throw Error('Invalid command invocation lease; inspect before recovery');
|
|
84
|
-
|
|
85
|
-
|
|
77
|
+
const foreign=typeof lease.pidNamespace==='string'&&lease.pidNamespace!==pidNamespace;
|
|
78
|
+
if(!foreign) {try {process.kill(lease.pid,0);return true;} catch(error) {if(error.code!=='ESRCH'&&error.code!=='EPERM')throw error;}}
|
|
79
|
+
const state=await run(['container','inspect','--format','{{.State.Running}}',lease.container],{capture:true});
|
|
80
|
+
// Foreign leases: the call container's own state is the only liveness signal.
|
|
81
|
+
if(state.code===0&&foreign&&state.stdout.trim()==='true')return true;
|
|
86
82
|
if(state.code===0)throw Error(`Stale command invocation lease; inspect container ${lease.container} before recovery`);
|
|
87
83
|
if(!/No such (?:object|container)/i.test(state.stderr+state.stdout))throw Error('Unable to verify a stale command invocation lease; inspect Docker before recovery');
|
|
88
84
|
await fs.rm(path.join(directory,file));
|
|
@@ -91,7 +87,7 @@ async function activeInvocations(home) {
|
|
|
91
87
|
}
|
|
92
88
|
async function invocationLease(home,container) {
|
|
93
89
|
const directory=invocationDirectory(home);await privateDir(directory);
|
|
94
|
-
const file=path.join(directory,randomUUID()+'.json');await fs.writeFile(file,JSON.stringify({pid:process.pid,container}),{mode:0o600,flag:'wx'});
|
|
90
|
+
const file=path.join(directory,randomUUID()+'.json');await fs.writeFile(file,JSON.stringify({pid:process.pid,container,...(pidNamespace?{pidNamespace}:{})}),{mode:0o600,flag:'wx'});
|
|
95
91
|
await stewardOwned(file);
|
|
96
92
|
return async()=>{await fs.rm(file,{force:true});};
|
|
97
93
|
}
|
|
@@ -150,9 +146,11 @@ export async function snapshot(source) {
|
|
|
150
146
|
return {source,files,manifest,deployment,sharedRevisions,revision:`sha256:${digest.digest('hex')}`};
|
|
151
147
|
}
|
|
152
148
|
export function validate(m,d,files) {
|
|
153
|
-
keys(m,['schemaVersion','id','version','description','commands','skills']);
|
|
149
|
+
keys(m,['schemaVersion','id','version','description','commands','skills','example']);
|
|
154
150
|
if(m.schemaVersion!==1 || (typeof m.version!=='string' || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(m.version))) throw Error('Unsupported manifest version');
|
|
155
151
|
id(m.id); strings(m.skills);
|
|
152
|
+
// One representative call shown by `tools list --details`; usage stays in --help/skill.
|
|
153
|
+
if(m.example!==undefined && (typeof m.example!=='string' || m.example.length>160 || /[\0-\x1f\x7f]/.test(m.example) || !Object.hasOwn(m.commands||{},m.example.split(' ')[0]))) throw Error('Manifest example must be one line of at most 160 characters starting with a declared command');
|
|
156
154
|
keys(d,['schemaVersion','services','commands','exports',...(d.schemaVersion>=2?['secrets']:[]),...(d.schemaVersion===3?['sharedServices']:[])]);
|
|
157
155
|
if(![1,2,3].includes(d.schemaVersion) || !d.services || !d.commands) throw Error('Unsupported deployment descriptor');
|
|
158
156
|
for(const [name,s] of Object.entries(d.services)) {
|
|
@@ -397,11 +395,13 @@ export function run(argv,{capture=false,container,signal,stdin,onStdout,onStart,
|
|
|
397
395
|
child.once('close',async(code,childSignal)=>{process.off('SIGTERM',term);process.off('SIGINT',int);
|
|
398
396
|
clearTimeout(killTimer);
|
|
399
397
|
clearTimeout(timeout);signal?.removeEventListener('abort',term);
|
|
400
|
-
|
|
398
|
+
// --rm only removes a container that ran. Compose stopping between
|
|
399
|
+
// create and start (cancelled, signalled or failed) strands it Created.
|
|
400
|
+
if(container&&(cancelled||code!==0)) {
|
|
401
401
|
try {
|
|
402
402
|
await removeCommandContainer(container);
|
|
403
403
|
} catch(error) {
|
|
404
|
-
const uncertain=error instanceof Error?error:Error('
|
|
404
|
+
const uncertain=error instanceof Error?error:Error('Command container cleanup failed');
|
|
405
405
|
uncertain.containerCleanupUncertain=true;return reject(uncertain);
|
|
406
406
|
}
|
|
407
407
|
}
|
|
@@ -409,7 +409,7 @@ export function run(argv,{capture=false,container,signal,stdin,onStdout,onStart,
|
|
|
409
409
|
resolve({code:cancelled?130:code??(childSignal?130:1),stdout,stderr});});
|
|
410
410
|
});
|
|
411
411
|
}
|
|
412
|
-
// Compose --rm can race
|
|
412
|
+
// Compose --rm can race this cleanup. Confirm disappearance instead of treating
|
|
413
413
|
// Docker's in-progress removal as either failure or completed cleanup.
|
|
414
414
|
export async function removeCommandContainer(container,execute=run) {
|
|
415
415
|
const cleanup=await execute(['container','rm','--force',container],{capture:true});
|
|
@@ -422,7 +422,7 @@ export async function removeCommandContainer(container,execute=run) {
|
|
|
422
422
|
await new Promise(resolve=>setTimeout(resolve,100));
|
|
423
423
|
}
|
|
424
424
|
}
|
|
425
|
-
throw Error(`
|
|
425
|
+
throw Error(`Command container cleanup failed: ${cleanup.stderr||cleanup.stdout}`);
|
|
426
426
|
}
|
|
427
427
|
|
|
428
428
|
async function checked(args) {
|
|
@@ -530,7 +530,6 @@ export async function init(home,workspace,catalogFile,hostConfig,standalone=fals
|
|
|
530
530
|
if(standalone && hostConfig) throw Error('Standalone setup cannot bind a relay host config');
|
|
531
531
|
if(typeof home!=='string'||typeof workspace!=='string'||!path.isAbsolute(home)||!path.isAbsolute(workspace)||/[\r\n\0$:,]/.test(home+workspace)) throw Error('Explicit absolute home/workspace required');
|
|
532
532
|
workspace=await fs.realpath(workspace);await privateDir(home);home=await fs.realpath(home);if(hostConfig)hostConfig=await fs.realpath(hostConfig);
|
|
533
|
-
await toolDiscoveryBlock(home,hostConfig);
|
|
534
533
|
if(await fs.lstat(path.join(home,'registry.json')).catch(()=>null)) throw Error('Registry already exists; refusing replacement');
|
|
535
534
|
catalogFile=path.resolve(catalogFile||fileURLToPath(new URL('../../default-plugins.json',import.meta.url)));
|
|
536
535
|
const sources=await json(catalogFile);
|
|
@@ -563,7 +562,6 @@ export async function init(home,workspace,catalogFile,hostConfig,standalone=fals
|
|
|
563
562
|
agent.binDir=bin;agent.toolsHome=home;await atomic(hostConfig,host);
|
|
564
563
|
}
|
|
565
564
|
});
|
|
566
|
-
await bindToolDiscovery(home,workspace);
|
|
567
565
|
if(hostConfig && path.basename(hostConfig)==='host-executor.json') await (await import('../updates/binding.mjs')).bindUpdates(home,hostConfig);
|
|
568
566
|
return {ok:true,launcher:path.join(home,'bin','ez'),workspace};
|
|
569
567
|
}
|
|
@@ -651,12 +649,17 @@ export async function main(args) {
|
|
|
651
649
|
return emit({plugin:name,revision:record.revision,path:skill,content:await fs.readFile(file,'utf8')});
|
|
652
650
|
}
|
|
653
651
|
if(action==='list') {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
652
|
+
// Self-sufficient index: aliases, purpose, skill and one call per plugin.
|
|
653
|
+
if(group==='tools' && args.length===1 && args[0]==='--details')return emit(Object.fromEntries(Object.entries(r.plugins).map(([name,p])=>{
|
|
654
|
+
const aliases=Object.keys(p.manifest.commands);
|
|
655
|
+
return [name,{
|
|
656
|
+
aliases,
|
|
657
|
+
purpose:typeof p.manifest.description==='string'?p.manifest.description.replace(/\s+/g,' ').trim().slice(0,200):'',
|
|
658
|
+
skills:p.manifest.skills.map(skill=>path.join(p.source,skill)),
|
|
659
|
+
skillReads:p.manifest.skills.map(skill=>['ez','tools','skill',name,skill]),
|
|
660
|
+
example:`ez ${p.manifest.example ?? `${aliases[0]} --help`}`,
|
|
661
|
+
}];
|
|
662
|
+
})));
|
|
660
663
|
if(args.length)throw Error('Use tools list [--details] or plugins list');
|
|
661
664
|
return emit(group==='tools'?r.commands:r.plugins);
|
|
662
665
|
}
|
|
@@ -23,7 +23,7 @@ export async function recoverNativeLease(home) {
|
|
|
23
23
|
export async function workspaceLease(home,owner={kind:'plugin'}) {
|
|
24
24
|
const file=path.join(home,'workspace-writer.lock'),temp=file+'.'+randomUUID()+'.tmp';
|
|
25
25
|
await fs.writeFile(temp,JSON.stringify({...owner,pid:process.pid}),{flag:'wx',mode:0o600});
|
|
26
|
-
try {await fs.link(temp,file);}catch(error){if(error.code!=='EEXIST')throw error;let prior;try {prior=await leaseOwner(file);}catch(error){if(error.code==='ENOENT')return undefined;throw error;}if(!prior.alive)throw Error('Stale workspace-writer.lock: inspect stopped owner and command containers, then restart for recovery');return undefined;}
|
|
26
|
+
try {await fs.link(temp,file);}catch(error){if(error.code!=='EEXIST')throw error;let prior;try {prior=await leaseOwner(file);}catch(error){if(error.code==='ENOENT')return undefined;throw error;}if(!prior.alive)throw Error('Stale workspace-writer.lock: inspect stopped owner and command containers, then restart for recovery');if(owner.kind==='native'&&prior.owner.kind==='native')return async()=>{};return undefined;}
|
|
27
27
|
finally {await fs.rm(temp);}
|
|
28
28
|
return async()=>{await fs.rm(file);};
|
|
29
29
|
}
|
package/src/runs.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { validApplicationOrigin, type ApplicationOrigin } from './application-origin.js'
|
|
2
2
|
import { type FailureEvidence, type FailureReview, validFailureReview, failureStamp, failureEvidence } from './failure.js'
|
|
3
3
|
import { randomBytes } from 'node:crypto'
|
|
4
|
+
import { readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { isHostRunId } from './host-executor-protocol.js'
|
|
4
7
|
import { validOrigin, type ExternalOrigin } from './event-sources.js'
|
|
5
8
|
import { normalizeReactionEmoji } from './reaction.js'
|
|
6
9
|
import { validScheduledOrigin, type ScheduledOrigin } from './scheduler.js'
|
|
@@ -36,6 +39,8 @@ export type RunRecord = {
|
|
|
36
39
|
// Ordered task setups tried for this occurrence; the last one is active.
|
|
37
40
|
attempts?: SetupAttempt[]
|
|
38
41
|
replyOnly?: boolean
|
|
42
|
+
// Silent end-of-conversation turn in a replaced chat session (session-close.ts).
|
|
43
|
+
sessionClose?: true
|
|
39
44
|
exitCode?: number | null
|
|
40
45
|
failureReason?: string
|
|
41
46
|
failure?: FailureEvidence
|
|
@@ -86,9 +91,10 @@ export const retainedOutbox = (controlDir: string): StoredOutboxItem[] => [...ou
|
|
|
86
91
|
// Stateless pipe: the relay owns the ledger in process memory. Cross-process
|
|
87
92
|
// producers (engine children, plugin children, host executor) reach it through
|
|
88
93
|
// the delivery socket (see delivery-socket.ts), never through control/ files.
|
|
89
|
-
// A restart drops in-flight runs and queued items by design
|
|
90
|
-
//
|
|
91
|
-
// terminal records so memory
|
|
94
|
+
// A restart drops in-flight runs and queued items by design (except handed-off
|
|
95
|
+
// host-transport runs, below); in-flight sends report unknown rather than
|
|
96
|
+
// claiming success. Long-lived relays cap retained terminal records so memory
|
|
97
|
+
// stays bounded.
|
|
92
98
|
const MAX_TERMINAL_RUNS = 2000
|
|
93
99
|
const MAX_TERMINAL_OUTBOX = 2000
|
|
94
100
|
|
|
@@ -142,6 +148,53 @@ export const activeSetup = (run: Pick<RunRecord, 'attempts' | 'execution'>): AiP
|
|
|
142
148
|
|
|
143
149
|
export const newRunId = (): string => `r_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`
|
|
144
150
|
|
|
151
|
+
// The one exception to memory-only runs: a graceful relay stop hands its
|
|
152
|
+
// running host-transport runs to the next relay, whose executors outlive it.
|
|
153
|
+
// The next relay adopts and watches them; it never relaunches one.
|
|
154
|
+
const handoffFile = (controlDir: string): string => join(controlDir, 'run-handoff.json')
|
|
155
|
+
const handedRun = (value: unknown): value is RunRecord => {
|
|
156
|
+
const run = value as RunRecord
|
|
157
|
+
return Boolean(run && typeof run === 'object' && (run.version === 1 || run.version === 2) && typeof run.id === 'string' && isHostRunId(run.id) &&
|
|
158
|
+
run.status === 'running' && Array.isArray(run.texts) && run.texts.every(text => typeof text === 'string') && Number.isFinite(Date.parse(run.createdAt)) &&
|
|
159
|
+
(run.execution === undefined || isExecutionChoice(run.execution)) && (run.scheduled === undefined || validScheduledOrigin(run.scheduled)) &&
|
|
160
|
+
(run.external === undefined || validOrigin(run.external)) && (run.application === undefined || validApplicationOrigin(run.application)))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const saveRunHandoff = async (controlDir: string, records: RunRecord[]): Promise<void> => {
|
|
164
|
+
const file = handoffFile(controlDir), temporary = `${file}.${process.pid}.tmp`
|
|
165
|
+
const ids=new Set(records.map(run=>run.id))
|
|
166
|
+
const receipts=[...outboxFor(controlDir).values()].filter(item=>item.runId&&ids.has(item.runId)&&(item.state==='sent'||item.state==='failed'))
|
|
167
|
+
await writeFile(temporary, JSON.stringify({ version: 1, writtenAt: new Date().toISOString(), runs: records, receipts }), { mode: 0o600 })
|
|
168
|
+
await rename(temporary, file)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const HANDOFF_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
|
172
|
+
|
|
173
|
+
// Reads and removes the record. An unreadable, corrupt or stale record adopts
|
|
174
|
+
// nothing: those executors finish unobserved, like a restart without handoff.
|
|
175
|
+
export const takeRunHandoff = async (controlDir: string, now = Date.now()): Promise<RunRecord[]> => {
|
|
176
|
+
try {
|
|
177
|
+
let raw: string
|
|
178
|
+
try { raw = await readFile(handoffFile(controlDir), 'utf8') }
|
|
179
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error }
|
|
180
|
+
await rm(handoffFile(controlDir))
|
|
181
|
+
const value = JSON.parse(raw) as { version?: unknown; writtenAt?: unknown; runs?: unknown; receipts?: unknown }
|
|
182
|
+
const age = now - Date.parse(typeof value.writtenAt === 'string' ? value.writtenAt : '')
|
|
183
|
+
if (value.version !== 1 || !(age >= -60_000 && age <= HANDOFF_MAX_AGE_MS)) throw new Error('Invalid or stale run handoff')
|
|
184
|
+
if (!Array.isArray(value.runs) || value.runs.length > 100) throw new Error('Invalid run handoff')
|
|
185
|
+
const valid = value.runs.filter(handedRun)
|
|
186
|
+
if (valid.length !== value.runs.length) console.error('Ignoring invalid handed-off runs', value.runs.length - valid.length)
|
|
187
|
+
const ids=new Set(valid.map(run=>run.id))
|
|
188
|
+
if(Array.isArray(value.receipts)&&value.receipts.length<=MAX_TERMINAL_OUTBOX){
|
|
189
|
+
for(const item of value.receipts as StoredOutboxItem[])if(item&&typeof item.id==='string'&&/^[a-zA-Z0-9_-]+$/.test(item.id)&&item.runId&&ids.has(item.runId)&&(item.state==='sent'||item.state==='failed'))outboxFor(controlDir).set(item.id,item)
|
|
190
|
+
}
|
|
191
|
+
return [...new Map(valid.map(run => [run.id, run])).values()]
|
|
192
|
+
} catch (error) {
|
|
193
|
+
console.error('Ignoring unreadable run handoff', error instanceof Error ? error.message : 'Unknown error')
|
|
194
|
+
return []
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
145
198
|
export class RunStore {
|
|
146
199
|
constructor(private readonly controlDir: string) {}
|
|
147
200
|
|
|
@@ -162,6 +215,7 @@ export class RunStore {
|
|
|
162
215
|
application?: ApplicationOrigin
|
|
163
216
|
delivery?: { bindingId: string; scope: string }
|
|
164
217
|
script?: ScriptRunRef
|
|
218
|
+
sessionClose?: true
|
|
165
219
|
}, exclusiveSchedule = false): Promise<RunRecord> {
|
|
166
220
|
const store = runsFor(this.controlDir)
|
|
167
221
|
if (input.id) {
|
|
@@ -194,6 +248,7 @@ export class RunStore {
|
|
|
194
248
|
delivery: input.delivery,
|
|
195
249
|
scheduled: input.scheduled,
|
|
196
250
|
...(input.script ? { script: input.script } : {}),
|
|
251
|
+
...(input.sessionClose ? { sessionClose: true as const } : {}),
|
|
197
252
|
status: 'queued',
|
|
198
253
|
createdAt: new Date().toISOString(),
|
|
199
254
|
}
|
|
@@ -207,6 +262,15 @@ export class RunStore {
|
|
|
207
262
|
return runsFor(this.controlDir).get(id) ?? null
|
|
208
263
|
}
|
|
209
264
|
|
|
265
|
+
// Re-admit a run from the previous relay's handoff; its executor is already running.
|
|
266
|
+
async adopt(run: RunRecord): Promise<RunRecord> {
|
|
267
|
+
if (!handedRun(run)) throw new Error('Invalid handed-off run')
|
|
268
|
+
const existing = runsFor(this.controlDir).get(run.id)
|
|
269
|
+
if (existing && existing.status !== 'running') throw new Error('Handed-off run already settled')
|
|
270
|
+
runsFor(this.controlDir).set(run.id, run)
|
|
271
|
+
return run
|
|
272
|
+
}
|
|
273
|
+
|
|
210
274
|
async patch(
|
|
211
275
|
id: string,
|
|
212
276
|
change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview' | 'externalReleased' | 'output' | 'timedOut' | 'attempts'>>,
|
package/src/schedule-cli.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { ControlStore, sameOwner } from './control-state.js'
|
|
|
8
8
|
import { ApplicationBindings } from './application-channel.js'
|
|
9
9
|
import type { RunRecord } from './runs.js'
|
|
10
10
|
import { callDeliverySocket, socketPathFor } from './delivery-socket.js'
|
|
11
|
-
import { initialPreset, isPreset, presetLabel,
|
|
11
|
+
import { initialPreset, isPreset, presetLabel, type AiPreset } from './ai.js'
|
|
12
12
|
import { executionOverrides } from './model-policy.js'
|
|
13
13
|
import { holdsSchedule, Scheduler, executionType } from './scheduler.js'
|
|
14
14
|
import { Scripts, fileSha256, workspaceEntry, DEFAULT_SCRIPT_TIMEOUT_SECONDS } from './scripts.js'
|
|
@@ -17,17 +17,18 @@ import { provisionedHome } from './auth-profile.js'
|
|
|
17
17
|
import { ownsRun } from './identity.js'
|
|
18
18
|
import { type Trigger } from './schedule-time.js'
|
|
19
19
|
|
|
20
|
-
//
|
|
20
|
+
// Each fallback selects its own native engine, credential home and model.
|
|
21
21
|
const fallbackSetup = (spec: string, index: number): AiPreset => {
|
|
22
22
|
const fields = Object.fromEntries(spec.split(',').map(part => {
|
|
23
23
|
const at = part.indexOf('=')
|
|
24
|
-
if (at < 1) throw new Error('Fallback uses cli=EXECUTOR,model=MODEL[,effort=EFFORT][,provider=ID]')
|
|
24
|
+
if (at < 1) throw new Error('Fallback uses cli=EXECUTOR,model=MODEL[,auth-profile=NAME][,effort=EFFORT][,provider=ID]')
|
|
25
25
|
return [part.slice(0, at), part.slice(at + 1)]
|
|
26
26
|
}))
|
|
27
|
-
if (Object.keys(fields).some(key => !['cli','model','effort','provider'].includes(key)) || !fields.cli || !fields.model)
|
|
28
|
-
throw new Error('Fallback uses cli=EXECUTOR,model=MODEL[,effort=EFFORT][,provider=ID]')
|
|
27
|
+
if (Object.keys(fields).some(key => !['cli','model','auth-profile','effort','provider'].includes(key)) || !fields.cli || !fields.model)
|
|
28
|
+
throw new Error('Fallback uses cli=EXECUTOR,model=MODEL[,auth-profile=NAME][,effort=EFFORT][,provider=ID]')
|
|
29
29
|
const base = initialPreset(fields.cli)
|
|
30
|
-
const preset = executionOverrides(base.cli, {...base, ...(fields.provider ? {provider: fields.provider} : {})
|
|
30
|
+
const preset = executionOverrides(base.cli, {...base, ...(fields.provider ? {provider: fields.provider} : {}),
|
|
31
|
+
...(fields['auth-profile'] !== undefined ? {authProfile: fields['auth-profile']} : {})}, fields.model, fields.effort)
|
|
31
32
|
const named = {...preset, id: `fallback-${index + 1}`, name: presetLabel(preset).slice(0, 80)}
|
|
32
33
|
if (!isPreset(named)) throw new Error('Invalid fallback setup')
|
|
33
34
|
return named
|
|
@@ -49,7 +50,7 @@ async function main() {
|
|
|
49
50
|
create [ID] | edit ID --name NAME (--text TEXT | --text-file FILE | --script SCRIPT_ID [--arg=VALUE ...])
|
|
50
51
|
--now | --at ISO_WITH_OFFSET | --every-seconds N | --cron 'MIN HOUR DAY MONTH WEEKDAY' --timezone IANA
|
|
51
52
|
[--cli EXECUTOR] [--auth-profile NAME] [--model MODEL] [--effort <native-effort>]
|
|
52
|
-
[--fallback cli=EXECUTOR,model=MODEL[,effort=EFFORT][,provider=ID] ... | --clear-fallbacks]
|
|
53
|
+
[--fallback cli=EXECUTOR,model=MODEL[,auth-profile=NAME][,effort=EFFORT][,provider=ID] ... | --clear-fallbacks]
|
|
53
54
|
[--preflight-file FILE | --clear-preflight]
|
|
54
55
|
[--start ISO_WITH_OFFSET] [--until ISO_WITH_OFFSET] [--when unreviewed-failures]
|
|
55
56
|
script list | show SCRIPT_ID | remove SCRIPT_ID
|
|
@@ -71,14 +72,19 @@ Failures default to unreviewed owner runs. Review records a diagnosis; it never
|
|
|
71
72
|
A conditional review schedule consumes no model run when there are no unreviewed failures.
|
|
72
73
|
New tasks capture the selected engine settings; edit preserves existing settings unless overridden.
|
|
73
74
|
Every task requires a concrete saved model. Supply --model if the selected settings have none; existing tasks never fall back to chat or client defaults.
|
|
74
|
-
Fallbacks
|
|
75
|
-
rejected the turn before any work began
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
Fallbacks run in the saved order only when the previous setup's CLI is unavailable or its provider
|
|
76
|
+
rejected the turn before any work began, including an unavailable login. A Claude usage limit the run ended on
|
|
77
|
+
after work began continues on the next setup in a fresh session, told the stopped session and that its work may be
|
|
78
|
+
partly applied. Account/organization access denial, unknown errors, cancellation and other failures after work
|
|
79
|
+
began stop the chain; nothing is replayed.
|
|
80
|
+
A login or quota rejection skips later setups of the same native login profile
|
|
81
|
+
or upstream provider. Named profiles must be owner-provisioned logins for separate accounts; Ez never enables overage.
|
|
82
|
+
The run records each attempt, setup, failure category and any reported reset time.
|
|
78
83
|
Trigger runs an existing task once with its saved instructions, AI and delivery binding in a fresh session. Reuse the request key after an uncertain result; the regular schedule is unchanged.
|
|
79
84
|
Creates a scheduled task. Instructions are text, never shell commands.
|
|
80
85
|
Use --now to run once. Run completion is not delivery proof.
|
|
81
|
-
Edit replaces the full schedule
|
|
86
|
+
Edit replaces the full schedule but keeps its paused/enabled state; only pause/resume toggle it.
|
|
87
|
+
Pause/remove affect future work; cancel stops a particular run.
|
|
82
88
|
Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/weekday OR semantics.`);return}
|
|
83
89
|
const config=loadControlConfig(), control=new ControlStore(config.controlDir,config.pairingTtlMs)
|
|
84
90
|
const owner=(await control.status()).owner
|
|
@@ -190,13 +196,15 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
190
196
|
v.cron ? {cron:v.cron,timezone:v.timezone!,start,until:v.until} : {everySeconds:Number(v['every-seconds']),start,until:v.until}
|
|
191
197
|
if(v['clear-preflight'] && v['preflight-file']) throw Error('Choose one preflight operation')
|
|
192
198
|
const previousSchedule = action === 'edit' ? await scheduler.get(id!) : undefined
|
|
199
|
+
// Edit keeps the paused/enabled state; only pause/resume toggle it.
|
|
200
|
+
const enabled = previousSchedule?.enabled ?? true
|
|
193
201
|
const preflight = v['clear-preflight'] ? undefined : v['preflight-file'] ? JSON.parse(await readFile(v['preflight-file'],'utf8')) : previousSchedule?.preflight
|
|
194
202
|
const origin = caller?.application ?? caller?.delivery
|
|
195
203
|
const delivery = previousSchedule ? previousSchedule.delivery : (origin ? {bindingId:origin.bindingId,scope:origin.scope} : undefined)
|
|
196
204
|
if (!delivery && !owner.telegramChatId) throw new Error('Create the schedule from an authenticated channel turn to bind its reply destination')
|
|
197
205
|
if (v.script) {
|
|
198
206
|
result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || v.script,preflight,
|
|
199
|
-
originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:'',when:v.when as 'unreviewed-failures' | undefined,trigger,enabled
|
|
207
|
+
originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:'',when:v.when as 'unreviewed-failures' | undefined,trigger,enabled,owner,
|
|
200
208
|
script:{id:v.script,args:v.arg ?? []}},action==='create'))
|
|
201
209
|
console.log(JSON.stringify(result,null,2));return
|
|
202
210
|
}
|
|
@@ -207,10 +215,11 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
207
215
|
const preset = executionOverrides(base.cli, v['auth-profile'] === undefined ? base : {...base, authProfile:v['auth-profile']}, v.model, v.effort)
|
|
208
216
|
if (!isPreset(preset)) throw new Error('Invalid task AI selection')
|
|
209
217
|
const fallbacks = v['clear-fallbacks'] ? undefined : v.fallback ? v.fallback.map(fallbackSetup) : previous?.fallbacks
|
|
210
|
-
|
|
218
|
+
for (const setup of [preset, ...fallbacks ?? []])
|
|
219
|
+
if (setup.authProfile !== undefined) await provisionedHome(config.controlDir, setup.cli, setup.authProfile)
|
|
211
220
|
result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || 'Task',
|
|
212
221
|
preflight,
|
|
213
|
-
originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:v.text || await readFile(v['text-file']!,'utf8'),when:v.when as 'unreviewed-failures' | undefined,trigger,enabled
|
|
222
|
+
originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:v.text || await readFile(v['text-file']!,'utf8'),when:v.when as 'unreviewed-failures' | undefined,trigger,enabled,owner,
|
|
214
223
|
execution:{sessionId:previous?.sessionId || randomUUID(),preset,...(fallbacks ? {fallbacks} : {})}},action==='create'))
|
|
215
224
|
}else{
|
|
216
225
|
if(!id)throw new Error('ID required')
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { executionDefaults } from './model-policy.js'
|
|
2
|
-
import { presetLabel } from './ai.js'
|
|
2
|
+
import { presetLabel, type AiPreset } from './ai.js'
|
|
3
|
+
import { AUTH_PROFILE_CLIS } from './auth-profile.js'
|
|
3
4
|
import type { Owner } from './control-state.js'
|
|
4
|
-
import type { Schedule,
|
|
5
|
+
import type { Schedule, ScheduledTaskView } from './scheduler.js'
|
|
5
6
|
|
|
6
7
|
const ownsSchedule = (owner: Owner, schedule: Schedule) =>
|
|
7
8
|
schedule.owner.telegramUserId === owner.telegramUserId &&
|
|
@@ -11,15 +12,45 @@ const ownsSchedule = (owner: Owner, schedule: Schedule) =>
|
|
|
11
12
|
// Saved schedules created under an earlier model policy remain inspectable. The
|
|
12
13
|
// executor will apply the same defaults (and enforce its current policy) when
|
|
13
14
|
// it starts the run; an outdated saved selection must not hide every menu row.
|
|
14
|
-
const displayedPreset = (
|
|
15
|
-
try { return executionDefaults(
|
|
16
|
-
catch { return
|
|
15
|
+
const displayedPreset = (preset: AiPreset) => {
|
|
16
|
+
try { return executionDefaults(preset.cli, preset) }
|
|
17
|
+
catch { return preset }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const setupLabel = (preset: AiPreset) => {
|
|
21
|
+
const label = presetLabel(displayedPreset(preset))
|
|
22
|
+
return AUTH_PROFILE_CLIS.includes(preset.cli) && preset.authProfile === undefined
|
|
23
|
+
? `${preset.cli} (default login)${label.slice(preset.cli.length)}` : label
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
// Script schedules have no AI preset; show what core actually invokes.
|
|
20
|
-
const engineLabel = (schedule:
|
|
27
|
+
const engineLabel = (schedule: ScheduledTaskView) => schedule.script
|
|
21
28
|
? `Script · ${schedule.script.id} · ${schedule.scriptRevision ? `rev ${schedule.scriptRevision.slice(0, 8)}` : 'not registered'}`
|
|
22
|
-
:
|
|
29
|
+
: [schedule.execution!.preset, ...schedule.execution!.fallbacks ?? []].map(setupLabel).join(' → ')
|
|
30
|
+
|
|
31
|
+
const every = (count: number, unit: string) => `Every ${count === 1 ? '' : `${count} `}${unit}${count === 1 ? '' : 's'}`
|
|
32
|
+
const frequency = (schedule: Schedule) => {
|
|
33
|
+
const trigger = schedule.trigger
|
|
34
|
+
if ('at' in trigger) return 'Once'
|
|
35
|
+
if ('everySeconds' in trigger) {
|
|
36
|
+
const seconds = trigger.everySeconds
|
|
37
|
+
for (const [size, unit] of [[86400,'day'],[3600,'hour'],[60,'minute']] as const)
|
|
38
|
+
if (seconds % size === 0) return every(seconds / size, unit)
|
|
39
|
+
return every(seconds, 'second')
|
|
40
|
+
}
|
|
41
|
+
const cron = trigger.cron.trim().replace(/\s+/g, ' ')
|
|
42
|
+
const [minute, hour, day, month, weekday] = cron.split(' ')
|
|
43
|
+
let label = `Cron ${cron}`
|
|
44
|
+
if (`${hour} ${day} ${month} ${weekday}` === '* * * *') {
|
|
45
|
+
const step = /^\*\/(\d+)$/.exec(minute)
|
|
46
|
+
if (minute === '*') label = 'Every minute'
|
|
47
|
+
else if (step && Number(step[1]) > 0 && 60 % Number(step[1]) === 0) label = every(Number(step[1]), 'minute')
|
|
48
|
+
else if (/^\d+$/.test(minute)) label = `Hourly at :${minute.padStart(2,'0')}`
|
|
49
|
+
} else if (/^\d+$/.test(minute) && /^\d+$/.test(hour) && day === '*' && month === '*' && ['*','1-5'].includes(weekday)) {
|
|
50
|
+
label = `${weekday === '*' ? 'Daily' : 'Weekdays'} at ${hour.padStart(2,'0')}:${minute.padStart(2,'0')}`
|
|
51
|
+
}
|
|
52
|
+
return `${label} · ${trigger.timezone}`
|
|
53
|
+
}
|
|
23
54
|
|
|
24
55
|
const weekdays = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
|
|
25
56
|
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
|
|
@@ -36,41 +67,46 @@ const preview = (schedule: Schedule) => {
|
|
|
36
67
|
return chars.length > 140 ? chars.slice(0,139).join('')+'…' : sentence
|
|
37
68
|
}
|
|
38
69
|
|
|
39
|
-
const currentState = (schedule:
|
|
40
|
-
schedule.runState === 'running' ? '▶ Running' : schedule.runState === 'queued' ? '◌ Queued' :
|
|
70
|
+
const currentState = (schedule: ScheduledTaskView) =>
|
|
71
|
+
[schedule.runState === 'running' ? '▶ Running' : schedule.runState === 'queued' ? '◌ Queued' : undefined,
|
|
72
|
+
!schedule.enabled ? '⏸ Paused' : undefined].filter(Boolean).join(' · ') || '● Active'
|
|
41
73
|
|
|
42
|
-
const lastRun = (schedule:
|
|
74
|
+
const lastRun = (schedule: ScheduledTaskView) => {
|
|
43
75
|
if (!schedule.lastRun) return '— Not run yet'
|
|
44
76
|
const status = schedule.lastRun.status === 'completed' ? '✓ Completed' : schedule.lastRun.status === 'failed' ? '✕ Failed' : '⊘ Cancelled'
|
|
45
77
|
return `${status} · ${time(schedule.lastRun.at)}${schedule.lastRun.currentRevision ? '' : ' · previous version'}`
|
|
46
78
|
}
|
|
47
79
|
|
|
48
|
-
export const ownedScheduledTasks = (schedules:
|
|
80
|
+
export const ownedScheduledTasks = (schedules: ScheduledTaskView[], owner: Owner) =>
|
|
49
81
|
schedules.filter((schedule) => ownsSchedule(owner, schedule))
|
|
50
82
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
51
83
|
|
|
52
|
-
export const scheduledTasksText = (schedules:
|
|
84
|
+
export const scheduledTasksText = (schedules: ScheduledTaskView[], owner: Owner) => {
|
|
53
85
|
const owned = ownedScheduledTasks(schedules, owner)
|
|
54
|
-
if (!owned.length) return '📅 Scheduled tasks\n\nNo
|
|
55
|
-
|
|
56
|
-
|
|
86
|
+
if (!owned.length) return '📅 Scheduled tasks\n\nNo scheduled tasks.'
|
|
87
|
+
const paused = owned.filter(schedule => !schedule.enabled).length
|
|
88
|
+
return [`📅 Scheduled tasks · ${owned.length - paused} active · ${paused} paused`, 'Next/last times UTC. Use /tasks 1 for task details.', ...owned.map((schedule, index) =>
|
|
89
|
+
`${index + 1} · ${schedule.name}\n ${currentState(schedule)}\n Schedule · ${frequency(schedule)}\n Next · ${schedule.nextAt === null ? '—' : time(schedule.nextAt)}\n Last · ${lastRun(schedule)}\n Execution · ${engineLabel(schedule)}\n ${preview(schedule)}`
|
|
57
90
|
)].join('\n\n')
|
|
58
91
|
}
|
|
59
92
|
|
|
60
|
-
export const scheduledTaskDetailText = (schedule:
|
|
93
|
+
export const scheduledTaskDetailText = (schedule: ScheduledTaskView, number: number, total?: number) => {
|
|
61
94
|
const instructions = schedule.script ? preview(schedule) : schedule.text.trim()
|
|
62
95
|
const capped = instructions.length > 2000 ? instructions.slice(0, 2000) + '… (truncated; full text lives in the workspace)' : instructions
|
|
63
96
|
return [
|
|
64
97
|
`📅 ${number}${total === undefined ? '' : ` of ${total}`} · ${schedule.name}`,
|
|
65
98
|
currentState(schedule),
|
|
66
99
|
'',
|
|
100
|
+
'Schedule',
|
|
101
|
+
frequency(schedule),
|
|
102
|
+
'',
|
|
67
103
|
'Next run',
|
|
68
104
|
schedule.nextAt === null ? '—' : time(schedule.nextAt),
|
|
69
105
|
'',
|
|
70
106
|
'Last run',
|
|
71
107
|
lastRun(schedule),
|
|
72
108
|
'',
|
|
73
|
-
|
|
109
|
+
'Execution path',
|
|
74
110
|
engineLabel(schedule),
|
|
75
111
|
'',
|
|
76
112
|
schedule.script ? 'Script arguments' : 'Instructions',
|
package/src/scheduler.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type ScheduledRunSummary = {
|
|
|
29
29
|
at: string
|
|
30
30
|
currentRevision: boolean
|
|
31
31
|
}
|
|
32
|
-
export type
|
|
32
|
+
export type ScheduledTaskView = Schedule & { nextAt: number | null; runState?: 'queued' | 'running'; lastRun?: ScheduledRunSummary; scriptRevision?: string }
|
|
33
33
|
export type ScheduledOrigin = { id: string; revision: string; dueAt: string; pairedAt: string; originRunId?: string }
|
|
34
34
|
export const validScheduledOrigin = (v: unknown): v is ScheduledOrigin => {
|
|
35
35
|
const s = v as ScheduledOrigin
|
|
@@ -102,10 +102,9 @@ export class Scheduler {
|
|
|
102
102
|
return nextOccurrence(s.trigger,-1)
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
-
async
|
|
106
|
-
const active:
|
|
105
|
+
async listTasksReadOnly(runs: RunRecord[]): Promise<ScheduledTaskView[]> {
|
|
106
|
+
const active: ScheduledTaskView[] = []
|
|
107
107
|
for (const s of await this.listReadOnly()) {
|
|
108
|
-
if (!s.enabled) continue
|
|
109
108
|
const current = runs.filter(r => r.scheduled?.id === s.id && r.scheduled.revision === s.revision && ownsRun(s.owner,r))
|
|
110
109
|
const history = runs.filter(r => r.scheduled?.id === s.id && r.scheduled.pairedAt === s.owner.pairedAt && ownsRun(s.owner,r))
|
|
111
110
|
.filter((r): r is RunRecord & { status: ScheduledRunSummary['status'] } =>
|
|
@@ -113,11 +112,11 @@ export class Scheduler {
|
|
|
113
112
|
.sort((a,b) => (b.endedAt ?? b.startedAt ?? b.createdAt).localeCompare(a.endedAt ?? a.startedAt ?? a.createdAt))
|
|
114
113
|
const last = history[0]
|
|
115
114
|
const runState = current.some(r => r.status === 'running') ? 'running' : current.some(r => r.status === 'queued') ? 'queued' : undefined
|
|
116
|
-
if (!runState && current.some(r => holdsSchedule(s,r))) continue
|
|
115
|
+
if (s.enabled && !runState && current.some(r => holdsSchedule(s,r))) continue
|
|
117
116
|
try {
|
|
118
|
-
const nextAt = await this.pendingOccurrence(s)
|
|
117
|
+
const nextAt = s.enabled ? await this.pendingOccurrence(s) : null
|
|
119
118
|
const scriptRevision = s.script ? await new Scripts(this.controlDir).get(s.script.id).then(r => r.revision, () => undefined) : undefined
|
|
120
|
-
if (runState || nextAt !== null) active.push({...s,nextAt,runState,...(scriptRevision ? {scriptRevision} : {}),...(last ? {
|
|
119
|
+
if (!s.enabled || runState || nextAt !== null) active.push({...s,nextAt,runState,...(scriptRevision ? {scriptRevision} : {}),...(last ? {
|
|
121
120
|
lastRun: {status:last.status,at:last.endedAt ?? last.startedAt ?? last.createdAt,currentRevision:last.scheduled!.revision===s.revision},
|
|
122
121
|
} : {})})
|
|
123
122
|
}
|
|
@@ -144,7 +143,7 @@ export class Scheduler {
|
|
|
144
143
|
assertScheduledModel(preset.model)
|
|
145
144
|
assertEffort(preset.effort, preset.model, preset.cli)
|
|
146
145
|
}
|
|
147
|
-
const key = (p: AiPreset) => JSON.stringify([p.cli, p.provider, p.model, p.effort])
|
|
146
|
+
const key = (p: AiPreset) => JSON.stringify([p.cli, p.authProfile, p.provider, p.model, p.effort])
|
|
148
147
|
if (new Set(setups.map(key)).size !== setups.length) throw new Error('Task setups must be distinct')
|
|
149
148
|
s = {...input, execution, trigger:validateTrigger(input.trigger),version:1,revision:randomUUID()}
|
|
150
149
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SessionState, Owner } from './control-state.js'
|
|
2
|
+
import { ownerId, ownerEpoch } from './control-state.js'
|
|
3
|
+
import type { RunStore, RunRecord } from './runs.js'
|
|
4
|
+
|
|
5
|
+
// A chat conversation has no native end. Before one is replaced, its engine
|
|
6
|
+
// gets one silent turn in that same native session to keep durable facts in
|
|
7
|
+
// the workspace. Ez only transports this literal; the engine decides whether
|
|
8
|
+
// anything is written. No reply mechanism is offered, so nothing is delivered.
|
|
9
|
+
// Owner conversations only: a close turn carries owner authority, so a private
|
|
10
|
+
// application scope (which runs under its binding's narrower authority) gets none.
|
|
11
|
+
export const SESSION_CLOSE_PROMPT = '[conversation closing] This conversation is ending. Save anything a future conversation will need where AGENTS.md says to keep memory; otherwise do nothing.'
|
|
12
|
+
|
|
13
|
+
export async function queueSessionClose(runs: RunStore, owner: Owner | null, session: SessionState | null | undefined): Promise<RunRecord | undefined> {
|
|
14
|
+
if (!owner || !session?.hasStarted || !session.preset || session.applicationScope) return undefined
|
|
15
|
+
return runs.create({ ownerId: ownerId(owner), ownerEpoch: ownerEpoch(owner), texts: [SESSION_CLOSE_PROMPT],
|
|
16
|
+
execution: { sessionId: session.sessionId, preset: session.preset }, sessionClose: true })
|
|
17
|
+
}
|