agentlas 1.0.11 → 1.0.14
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/CHANGELOG.md +67 -0
- package/README.md +4 -2
- package/bin/agentlas.cjs +17 -3
- package/engine/agentlas-config.cjs +25 -18
- package/engine/agentlas-core-harness.cjs +14 -1
- package/engine/agentlas-i18n.cjs +2 -0
- package/engine/agentlas-input.cjs +62 -8
- package/engine/agentlas-memory-governance.cjs +10 -0
- package/engine/agentlas-onboard.cjs +22 -5
- package/engine/agentlas-sqlite-policy.cjs +18 -7
- package/engine/agentlas-workforce.cjs +989 -114
- package/engine/agentlas-workload-routing.cjs +61 -7
- package/engine/agentlas.cjs +8 -0
- package/engine/automation/daemon.cjs +76 -30
- package/engine/automation/schedule.cjs +16 -0
- package/engine/automation/store.cjs +92 -12
- package/engine/bootstrap-schema.sql +788 -29
- package/engine/commands/automation.cjs +8 -0
- package/engine/commands/chats.cjs +6 -1
- package/engine/commands/doctor.cjs +23 -1
- package/engine/commands/firm.cjs +66 -4
- package/engine/commands/help.cjs +31 -7
- package/engine/commands/hep-cloud.cjs +31 -0
- package/engine/commands/hep-hub.cjs +30 -0
- package/engine/commands/hep-local.cjs +32 -0
- package/engine/commands/hep-network.cjs +43 -0
- package/engine/commands/index.cjs +38 -7
- package/engine/commands/list.cjs +25 -1
- package/engine/commands/open.cjs +5 -1
- package/engine/commands/run.cjs +77 -13
- package/engine/commands/setup.cjs +12 -11
- package/engine/commands/storm.cjs +5 -9
- package/engine/commands/swarm.cjs +4 -4
- package/engine/commands/uninstall.cjs +36 -2
- package/engine/commands/version.cjs +29 -0
- package/engine/commands/workforce.cjs +10 -10
- package/engine/core/schema-ensure.cjs +75 -0
- package/engine/experience/variant.cjs +46 -2
- package/engine/firms/orchestrate.cjs +10 -3
- package/engine/hephaestus/runtime.cjs +7 -0
- package/engine/memory-cli/curate.cjs +3 -7
- package/engine/project/memory-context.cjs +3 -7
- package/engine/project/state.cjs +7 -6
- package/engine/runtimes/overrides.cjs +91 -22
- package/engine/runtimes/resolve.cjs +38 -8
- package/engine/runtimes/roles.cjs +162 -0
- package/engine/sessions/orchestrator.cjs +43 -4
- package/engine/sessions/prompt.cjs +4 -7
- package/engine/sessions/session.cjs +88 -20
- package/engine/storm/swarm.cjs +40 -12
- package/engine/ui/palette.cjs +52 -0
- package/engine/ui/renderer.cjs +37 -0
- package/engine/ui/repl.cjs +78 -10
- package/engine/workforce/capture.cjs +199 -14
- package/engine/workforce/concurrency.cjs +41 -0
- package/engine/workforce/deps.cjs +145 -29
- package/package.json +1 -1
|
@@ -17,7 +17,15 @@ const SCHEMA_VERSION = 1;
|
|
|
17
17
|
const ALLOCATION_SCHEMA = "agentlas.workload-allocation.v1";
|
|
18
18
|
const TIERS = Object.freeze(["economy", "balanced", "frontier"]);
|
|
19
19
|
const TIER_RANK = Object.freeze({ economy: 0, balanced: 1, frontier: 2 });
|
|
20
|
+
// 알려진 값은 랭크 폴백으로만 쓴다 — "유효한가" 게이트로 쓰지 않는다. 2026-07-28 라이브
|
|
21
|
+
// 실측: codex의 모델 카탈로그가 프론티어 모델 하나에서 "ultra"(자동 위임) 리즌
|
|
22
|
+
// 레벨을 광고했는데, normalizeEffort가 이 튜플로 게이트를 걸면 parent-AI의 할당
|
|
23
|
+
// 결정 전체가 통째로 null(무효) 처리된다. 새 값이 나올 때마다 이 튜플을 고치지
|
|
24
|
+
// 않고, resolveEffort가 모델 자체 목록의 순서(=능력 랭크, provider 계약)를
|
|
25
|
+
// 신뢰하도록 뒤집었다. (구체적 모델 id는 이 파일에 절대 하드코딩하지 않는다 —
|
|
26
|
+
// 아래 doesNotMatch 계약 테스트 참고.)
|
|
20
27
|
const EFFORTS = Object.freeze(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
28
|
+
const EFFORT_TOKEN_RE = /^[a-z][a-z0-9-]{0,23}$/;
|
|
21
29
|
const CAPABILITIES = new Set(["code", "image", "tools", "long-context"]);
|
|
22
30
|
const PHASES = new Set(["plan", "delegate", "synthesize"]);
|
|
23
31
|
|
|
@@ -31,8 +39,9 @@ function normalizeTier(value) {
|
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
function normalizeEffort(value) {
|
|
42
|
+
// 신택스만 검증한다 — 화이트리스트 존재는 게이트로 쓰지 않는다(위 EFFORTS 주석).
|
|
34
43
|
const v = String(value || "").toLowerCase().trim();
|
|
35
|
-
return
|
|
44
|
+
return EFFORT_TOKEN_RE.test(v) ? v : null;
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
function normalizeCapabilities(value) {
|
|
@@ -251,10 +260,29 @@ function resolveEffort(provider, requested, supported = []) {
|
|
|
251
260
|
const available = Array.isArray(supported) ? supported : [];
|
|
252
261
|
if (!available.length) return null;
|
|
253
262
|
if (available.includes(requested)) return requested;
|
|
254
|
-
|
|
263
|
+
// 이 모델이 정확히 그 값을 광고하지 않을 때만 여기로 온다. 랭크는 모델 자체
|
|
264
|
+
// 목록의 순서(=능력 랭크, provider 계약)를 최우선으로 쓰고, 알려진 7단계
|
|
265
|
+
// 표는 순서 정보가 없는 값의 폴백일 뿐이다 — 화이트리스트 게이트가 아니다.
|
|
266
|
+
// 미광고 known 값은 available 안에서 known-rank가 자기 이하인 마지막 항목
|
|
267
|
+
// 바로 뒤(소수 위치)에 끼워 넣는다 — 무조건 "목록 끝"으로 밀면 사실 available의
|
|
268
|
+
// 상위 항목들보다 낮은 값(예: low/xhigh/max만 있을 때의 "medium")이 전부 통과돼
|
|
269
|
+
// 요청보다 위로 에스컬레이션된다. 표에도 없는 완전 미지의 값만 +Infinity.
|
|
270
|
+
const rank = (value) => {
|
|
271
|
+
const own = available.indexOf(value);
|
|
272
|
+
if (own !== -1) return own;
|
|
273
|
+
const known = EFFORTS.indexOf(value);
|
|
274
|
+
if (known === -1) return Infinity;
|
|
275
|
+
let insertAfter = -1;
|
|
276
|
+
available.forEach((item, index) => {
|
|
277
|
+
const itemKnown = EFFORTS.indexOf(item);
|
|
278
|
+
if (itemKnown !== -1 && itemKnown <= known) insertAfter = index;
|
|
279
|
+
});
|
|
280
|
+
return insertAfter + 0.5;
|
|
281
|
+
};
|
|
282
|
+
const requestedRank = rank(requested);
|
|
255
283
|
const lower = available
|
|
256
|
-
.filter((item) =>
|
|
257
|
-
.sort((a, b) =>
|
|
284
|
+
.filter((item) => rank(item) <= requestedRank)
|
|
285
|
+
.sort((a, b) => rank(b) - rank(a))[0];
|
|
258
286
|
return lower || available[0] || null;
|
|
259
287
|
}
|
|
260
288
|
return null;
|
|
@@ -397,10 +425,32 @@ function resolveAllocationAcrossRuntimes(options = {}) {
|
|
|
397
425
|
return { ...resolution, runtime, runtimeId, requestedRuntimeId: requestedId || null };
|
|
398
426
|
}
|
|
399
427
|
|
|
400
|
-
function
|
|
428
|
+
function modelRoleForStage(stage) {
|
|
429
|
+
return ["plan", "planner", "leader", "verify", "verifier", "synthesize", "synthesis", "route", "clarify"]
|
|
430
|
+
.includes(cleanText(stage, 80).toLowerCase())
|
|
431
|
+
? "orchestrator"
|
|
432
|
+
: "worker";
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function normalizeObservedUsage(value) {
|
|
436
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
437
|
+
const inputTokens = value.inputTokens;
|
|
438
|
+
const outputTokens = value.outputTokens;
|
|
439
|
+
return Number.isInteger(inputTokens) && inputTokens >= 0
|
|
440
|
+
&& Number.isInteger(outputTokens) && outputTokens >= 0
|
|
441
|
+
? { inputTokens, outputTokens }
|
|
442
|
+
: null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function createDecisionReceipt({ taskId, stage, decision, resolution, role, usage }) {
|
|
401
446
|
const normalized = normalizeAllocation(decision);
|
|
402
447
|
const validationIssues = [];
|
|
403
|
-
|
|
448
|
+
const decisionProvided = decision != null;
|
|
449
|
+
if (!decisionProvided) validationIssues.push("allocation_not_provided");
|
|
450
|
+
else if (!normalized) validationIssues.push("invalid_ai_allocation");
|
|
451
|
+
const resolvedRole = role === "worker" || role === "orchestrator"
|
|
452
|
+
? role
|
|
453
|
+
: modelRoleForStage(stage);
|
|
404
454
|
const resolutionCodes = cleanText(resolution && resolution.fallbackReason, 500)
|
|
405
455
|
.split(",")
|
|
406
456
|
.map((code) => cleanText(code, 120))
|
|
@@ -411,12 +461,13 @@ function createDecisionReceipt({ taskId, stage, decision, resolution }) {
|
|
|
411
461
|
])].slice(0, 32);
|
|
412
462
|
const featurePayload = JSON.stringify(normalized ? {
|
|
413
463
|
phase: normalized.phase,
|
|
464
|
+
role: resolvedRole,
|
|
414
465
|
tier: normalized.tier,
|
|
415
466
|
effort: normalized.effort,
|
|
416
467
|
reasonCodes: normalized.reasonCodes,
|
|
417
468
|
requiredCapabilities: normalized.requiredCapabilities,
|
|
418
469
|
estimatedContextTokens: normalized.estimatedContextTokens,
|
|
419
|
-
} : { phase: cleanText(stage, 80) || null, allocation: null });
|
|
470
|
+
} : { phase: cleanText(stage, 80) || null, role: resolvedRole, allocation: null });
|
|
420
471
|
const featureHash = `sha256:${crypto.createHash("sha256").update(featurePayload, "utf8").digest("hex")}`;
|
|
421
472
|
const source = resolution && resolution.source;
|
|
422
473
|
const hasResolvedCurrent = Boolean(
|
|
@@ -439,6 +490,7 @@ function createDecisionReceipt({ taskId, stage, decision, resolution }) {
|
|
|
439
490
|
? normalized.decisionId
|
|
440
491
|
: `terminal:model-allocation:${featureHash.slice("sha256:".length, "sha256:".length + 24)}`,
|
|
441
492
|
packetId: cleanText(taskId, 255) || null,
|
|
493
|
+
role: resolvedRole,
|
|
442
494
|
status,
|
|
443
495
|
requested: {
|
|
444
496
|
tier: normalized ? normalized.tier : null,
|
|
@@ -460,6 +512,7 @@ function createDecisionReceipt({ taskId, stage, decision, resolution }) {
|
|
|
460
512
|
selectorVersion: normalized ? normalized.selectorVersion : "deterministic-host-fallback",
|
|
461
513
|
independentVerificationRequired:
|
|
462
514
|
riskCodes.has("high-risk") || riskCodes.has("critical-risk") || riskCodes.has("independent-verification"),
|
|
515
|
+
usage: normalizeObservedUsage(usage),
|
|
463
516
|
validationIssues,
|
|
464
517
|
privacy: { rawPromptIncluded: false, rawTranscriptIncluded: false },
|
|
465
518
|
};
|
|
@@ -522,6 +575,7 @@ module.exports = {
|
|
|
522
575
|
resolveAllocation,
|
|
523
576
|
resolveAllocationAcrossRuntimes,
|
|
524
577
|
createDecisionReceipt,
|
|
578
|
+
modelRoleForStage,
|
|
525
579
|
appendDecisionReceipt,
|
|
526
580
|
defaultReceiptPath,
|
|
527
581
|
plannerSystemPrompt,
|
package/engine/agentlas.cjs
CHANGED
|
@@ -102,6 +102,14 @@ function nearestCommands(token, names) {
|
|
|
102
102
|
|
|
103
103
|
function main() {
|
|
104
104
|
const argv = process.argv.slice(2);
|
|
105
|
+
const helpRequested = argv.some((arg) => arg === "--help" || arg === "-h");
|
|
106
|
+
const helpCommand = argv.find((arg) => arg !== "--help" && arg !== "-h" && !arg.startsWith("-"));
|
|
107
|
+
if (helpRequested && helpCommand) {
|
|
108
|
+
const ctx = buildCtx();
|
|
109
|
+
const command = commands.resolveCommandName(helpCommand);
|
|
110
|
+
const code = require("./commands/help.cjs").runForCommand(ctx, command);
|
|
111
|
+
process.exit(typeof code === "number" ? code : 0);
|
|
112
|
+
}
|
|
105
113
|
// 옵션 정규화: -h/--help/-V/--version 은 하위 명령으로 변환
|
|
106
114
|
const normalized = argv.map((a) => {
|
|
107
115
|
if (a === "--help" || a === "-h") return "help";
|
|
@@ -111,6 +111,41 @@ function resolveTargetAgent(db, row) {
|
|
|
111
111
|
return bySlug;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* 이 터미널이 실행할 수 없는 계열 판정 — 실행 게이트와 due 셀렉션의 유일한 정본.
|
|
116
|
+
* store.runnerUnsupportedSql 의 SQL 술어와 반드시 같은 조건을 본다(갈리면 굶주림이
|
|
117
|
+
* 되돌아온다: 셀렉션이 담은 행을 실행기가 거부하면 그 행이 due 창을 영구 점유한다).
|
|
118
|
+
*
|
|
119
|
+
* Hub 타깃: 데스크탑은 정확 릴리스 핀 + Hub 런타임으로 실행한다
|
|
120
|
+
* (automation-scheduler.ts:573-630 hub_version_pin 게이트) — 터미널에는 그 실행
|
|
121
|
+
* 계층이 없으므로 위장 실행 금지, 정직 스킵.
|
|
122
|
+
* tool_mode 'browser'/'computer-use': 데스크탑은 Agentlas Browser/컴퓨터유즈 러너를
|
|
123
|
+
* 배선하고 권한 프리플라이트까지 건다(automation-scheduler.ts:619-625). 터미널
|
|
124
|
+
* 세션 계층에는 그 러너가 없다 — 평문 세션으로 돌리는 조용한 다운그레이드 대신
|
|
125
|
+
* 정직 스킵으로 Desktop 실행분(회차)을 그대로 남겨 둔다.
|
|
126
|
+
* @returns {{reason:string, message:string}|null}
|
|
127
|
+
*/
|
|
128
|
+
function runnerSkip(db, row, ko) {
|
|
129
|
+
if (row.target_type === "hub") {
|
|
130
|
+
return {
|
|
131
|
+
reason: "hub-target-unsupported",
|
|
132
|
+
message: ko
|
|
133
|
+
? `Hub 타깃 자동화는 터미널 데몬이 실행하지 않습니다(정확 릴리스 핀 실행은 Desktop 스케줄러 몫): ${row.name}`
|
|
134
|
+
: `Hub-target automations are not run by the terminal daemon (exact-release Hub execution belongs to the Desktop scheduler): ${row.name}`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const rowToolMode = columnExists(db, "automations", "tool_mode") ? row.tool_mode : null;
|
|
138
|
+
if (rowToolMode === "browser" || rowToolMode === "computer-use") {
|
|
139
|
+
return {
|
|
140
|
+
reason: "tool-mode-unsupported",
|
|
141
|
+
message: ko
|
|
142
|
+
? `tool_mode '${rowToolMode}' 자동화는 터미널 데몬이 실행하지 않습니다(브라우저/컴퓨터유즈 러너는 Desktop 몫): ${row.name}`
|
|
143
|
+
: `tool_mode '${rowToolMode}' automations are not run by the terminal daemon (the browser/computer-use runner belongs to Desktop): ${row.name}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
114
149
|
/**
|
|
115
150
|
* 자동화 1건 실행(헤드리스). 권한은 자동화 행의 permission 열(있으면), 없으면 "write".
|
|
116
151
|
* opts:
|
|
@@ -131,26 +166,10 @@ async function runAutomationOnce(ctx, db, row, opts = {}) {
|
|
|
131
166
|
return { ok: false, skipped: true, reason: "disabled" };
|
|
132
167
|
}
|
|
133
168
|
// ── 터미널이 충실히 실행할 수 없는 계열은 리스를 잡지 않고 스킵한다 ──
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (row.target_type === "hub") {
|
|
139
|
-
ctx.err(ko
|
|
140
|
-
? `Hub 타깃 자동화는 터미널 데몬이 실행하지 않습니다(정확 릴리스 핀 실행은 Desktop 스케줄러 몫): ${row.name}`
|
|
141
|
-
: `Hub-target automations are not run by the terminal daemon (exact-release Hub execution belongs to the Desktop scheduler): ${row.name}`);
|
|
142
|
-
return { ok: false, skipped: true, reason: "hub-target-unsupported" };
|
|
143
|
-
}
|
|
144
|
-
// tool_mode 'browser'/'computer-use': 데스크탑은 Agentlas Browser/컴퓨터유즈
|
|
145
|
-
// 러너를 배선하고 권한 프리플라이트까지 건다(automation-scheduler.ts:619-625).
|
|
146
|
-
// 터미널 세션 계층에는 그 러너가 없다 — 평문 세션으로 돌리는 조용한 다운그레이드
|
|
147
|
-
// (위장 실행) 대신 정직 스킵으로 Desktop 실행분을 남겨 둔다.
|
|
148
|
-
const rowToolMode = columnExists(db, "automations", "tool_mode") ? row.tool_mode : null;
|
|
149
|
-
if (rowToolMode === "browser" || rowToolMode === "computer-use") {
|
|
150
|
-
ctx.err(ko
|
|
151
|
-
? `tool_mode '${rowToolMode}' 자동화는 터미널 데몬이 실행하지 않습니다(브라우저/컴퓨터유즈 러너는 Desktop 몫): ${row.name}`
|
|
152
|
-
: `tool_mode '${rowToolMode}' automations are not run by the terminal daemon (the browser/computer-use runner belongs to Desktop): ${row.name}`);
|
|
153
|
-
return { ok: false, skipped: true, reason: "tool-mode-unsupported" };
|
|
169
|
+
const unsupported = runnerSkip(db, row, ko);
|
|
170
|
+
if (unsupported) {
|
|
171
|
+
ctx.err(unsupported.message);
|
|
172
|
+
return { ok: false, skipped: true, reason: unsupported.reason };
|
|
154
173
|
}
|
|
155
174
|
if (!store.leaseSupported(db)) {
|
|
156
175
|
// 리스 열이 없는 DB에서는 Desktop 과의 배타성을 증명할 수 없다 — fail-closed.
|
|
@@ -167,6 +186,14 @@ async function runAutomationOnce(ctx, db, row, opts = {}) {
|
|
|
167
186
|
return { ok: false, skipped: true, reason: "lease" };
|
|
168
187
|
}
|
|
169
188
|
|
|
189
|
+
// 데스크탑 스케줄러는 60초마다 리스를 갱신한다. 여기는 한 번 잡고 끝이라
|
|
190
|
+
// TTL(15분)을 넘긴 실행은 프로세스가 살아 있어도 회수 대상이 됐다 — 같은
|
|
191
|
+
// 자동화가 두 실행기에서 겹쳐 도는 경로. 같은 주기로 심장박동을 보낸다.
|
|
192
|
+
const leaseHeartbeat = setInterval(() => {
|
|
193
|
+
try { store.renewAutomationLease(db, row.id); } catch { /* best-effort */ }
|
|
194
|
+
}, 60_000);
|
|
195
|
+
if (typeof leaseHeartbeat.unref === "function") leaseHeartbeat.unref();
|
|
196
|
+
|
|
170
197
|
try {
|
|
171
198
|
// ── raw-row 실행 계약 게이트 (데스크탑 automation-scheduler.ts:538-549 동형) ──
|
|
172
199
|
// 손상된 계약 값으로는 무인 실행하지 않는다 — 문구까지 데스크탑과 동일.
|
|
@@ -246,6 +273,7 @@ async function runAutomationOnce(ctx, db, row, opts = {}) {
|
|
|
246
273
|
store.advanceAfterRun(db, row, { ok: false, advanceSchedule: !!opts.advanceSchedule });
|
|
247
274
|
return { ok: false, error: msg };
|
|
248
275
|
} finally {
|
|
276
|
+
clearInterval(leaseHeartbeat);
|
|
249
277
|
store.releaseAutomation(db, row.id);
|
|
250
278
|
}
|
|
251
279
|
}
|
|
@@ -259,17 +287,31 @@ async function daemonTick(ctx, db, opts = {}) {
|
|
|
259
287
|
const nowIso = (opts.now || new Date()).toISOString();
|
|
260
288
|
let due = [];
|
|
261
289
|
try {
|
|
262
|
-
|
|
290
|
+
// 실행 창은 "이 실행기가 실제로 실행할 수 있는" 행만 담는다 (runnable: true).
|
|
291
|
+
// 미지원 계열/남의 리스 행은 스킵돼도 next_run_at 이 전진하지 않으므로, 창에
|
|
292
|
+
// 담으면 시간순 LIMIT 5 의 머리를 영구 점유해 뒤의 자동화를 전부 굶긴다.
|
|
293
|
+
due = store.dueAutomations(db, nowIso, 5, { runnable: true });
|
|
263
294
|
} catch (e) {
|
|
264
295
|
ctx.err("Failed to query due automations: " + String((e && e.message) || e));
|
|
265
296
|
return 0;
|
|
266
297
|
}
|
|
298
|
+
// 미지원 계열은 실행 창에서 빠졌어도 존재 사실은 정직하게 알린다 — Desktop 몫으로
|
|
299
|
+
// 남겨둔 회차이므로 틱마다 반복하지 않고 데몬 수명당 1회만 고지한다.
|
|
300
|
+
if (opts.skipAnnounced) {
|
|
301
|
+
let deferred = [];
|
|
302
|
+
// 고지 상한 50 — 실행 창(5)과 달리 이 목록은 실행에 쓰이지 않으므로 넉넉해도 안전하다.
|
|
303
|
+
try { deferred = store.dueAutomations(db, nowIso, 50, { runnable: false }); } catch { /* best-effort */ }
|
|
304
|
+
for (const row of deferred) {
|
|
305
|
+
if (opts.skipAnnounced.has(row.id)) continue;
|
|
306
|
+
const skip = runnerSkip(db, row, ctx.lang === "ko");
|
|
307
|
+
if (!skip) continue;
|
|
308
|
+
ctx.err(skip.message);
|
|
309
|
+
opts.skipAnnounced.add(row.id);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
267
312
|
let ran = 0;
|
|
268
313
|
for (const row of due) {
|
|
269
314
|
if (opts.shouldStop && opts.shouldStop()) break;
|
|
270
|
-
// 터미널 미지원 계열(hub 타깃/browser/computer-use)은 due 로 계속 남는다 —
|
|
271
|
-
// Desktop 몫으로 남겨둔 것이므로 틱마다 같은 안내를 반복하지 않는다.
|
|
272
|
-
if (opts.skipAnnounced && opts.skipAnnounced.has(row.id)) continue;
|
|
273
315
|
ran += 1;
|
|
274
316
|
const result = await runAutomationOnce(ctx, db, row, {
|
|
275
317
|
advanceSchedule: true,
|
|
@@ -279,12 +321,15 @@ async function daemonTick(ctx, db, opts = {}) {
|
|
|
279
321
|
spawnImpl: opts.spawnImpl,
|
|
280
322
|
timeoutConfig: opts.timeoutConfig,
|
|
281
323
|
});
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
) {
|
|
286
|
-
|
|
287
|
-
|
|
324
|
+
// 스킵은 회차를 소비하지 않는다 — 1회성 비활성화조차 건드리지 않는다.
|
|
325
|
+
// (미지원 계열은 Desktop 몫이고, lease 스킵은 지금 남이 돌리는 회차다. 셀렉션과
|
|
326
|
+
// 실행 사이의 리스 레이스로 스킵된 1회성 행을 여기서 꺼 버리면 그 실행은 영영 사라진다.)
|
|
327
|
+
if (result && result.skipped) {
|
|
328
|
+
if (
|
|
329
|
+
opts.skipAnnounced &&
|
|
330
|
+
(result.reason === "hub-target-unsupported" || result.reason === "tool-mode-unsupported")
|
|
331
|
+
) opts.skipAnnounced.add(row.id);
|
|
332
|
+
continue;
|
|
288
333
|
}
|
|
289
334
|
// 스케줄이 없는(1회성) 행이 남으면 재발화 방지 (v1과 동일).
|
|
290
335
|
if (!row.schedule || !schedule.nextAutomationRun(row)) {
|
|
@@ -331,5 +376,6 @@ module.exports = {
|
|
|
331
376
|
resolveTargetAgent,
|
|
332
377
|
automationContractState,
|
|
333
378
|
automationSessionChatId,
|
|
379
|
+
runnerSkip,
|
|
334
380
|
decodeRuntimeSelection,
|
|
335
381
|
};
|
|
@@ -102,6 +102,21 @@ function nextCronRun(cron, from = new Date(), timezone = null) {
|
|
|
102
102
|
return null;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* IANA 타임존으로 해석 가능한지만 본다(빈 값 = 로컬존이므로 유효).
|
|
107
|
+
* nextCronRun 은 cron 파싱 실패와 잘못된 존을 똑같이 null 로 접어버리므로,
|
|
108
|
+
* 호출부가 "어느 필드가 틀렸는지"를 구분하려면 이 검사가 따로 필요하다.
|
|
109
|
+
*/
|
|
110
|
+
function isValidTimezone(timezone) {
|
|
111
|
+
if (!timezone) return true;
|
|
112
|
+
try {
|
|
113
|
+
new Intl.DateTimeFormat("en-US", { timeZone: String(timezone) });
|
|
114
|
+
return true;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
105
120
|
function localTimezone() {
|
|
106
121
|
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; }
|
|
107
122
|
}
|
|
@@ -175,6 +190,7 @@ module.exports = {
|
|
|
175
190
|
cronField,
|
|
176
191
|
zonedDateParts,
|
|
177
192
|
nextCronRun,
|
|
193
|
+
isValidTimezone,
|
|
178
194
|
localTimezone,
|
|
179
195
|
legacyScheduleSpec,
|
|
180
196
|
nextAutomationRun,
|
|
@@ -105,6 +105,27 @@ function claimAutomation(db, id, now = new Date(), owner = LEASE_OWNER) {
|
|
|
105
105
|
return (result.changes ?? result.rowsAffected ?? 0) > 0;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* 이 프로세스가 아직 들고 있는 리스의 claimed_at 을 현재로 민다.
|
|
110
|
+
*
|
|
111
|
+
* claimAutomation 은 한 번만 부르고 갱신이 없었다. 데스크탑 스케줄러는 60초마다
|
|
112
|
+
* 갱신하므로, TTL(15분)을 넘긴 CLI 실행은 프로세스가 멀쩡히 살아 있는데도
|
|
113
|
+
* 회수 대상이 된다 — 에이전트 세션에서 15분은 평범하고, 그러면 같은 자동화가
|
|
114
|
+
* 두 실행기에서 겹쳐 돈다. 소유자가 나일 때만 갱신하므로, 이미 남에게 넘어간
|
|
115
|
+
* 리스를 되빼앗지는 않는다.
|
|
116
|
+
*/
|
|
117
|
+
function renewAutomationLease(db, id, now = new Date(), owner = LEASE_OWNER) {
|
|
118
|
+
if (!leaseSupported(db)) return false;
|
|
119
|
+
try {
|
|
120
|
+
const result = db
|
|
121
|
+
.prepare("UPDATE automations SET claimed_at = ? WHERE id = ? AND lease_owner = ?")
|
|
122
|
+
.run(now.toISOString(), id, owner);
|
|
123
|
+
return (result.changes ?? result.rowsAffected ?? 0) > 0;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
108
129
|
function releaseAutomation(db, id) {
|
|
109
130
|
try {
|
|
110
131
|
db.prepare("UPDATE automations SET claimed_at = NULL, lease_owner = NULL WHERE id = ?").run(id);
|
|
@@ -142,11 +163,28 @@ function listRuns(db, limit = 15) {
|
|
|
142
163
|
).all(Math.max(1, limit));
|
|
143
164
|
}
|
|
144
165
|
|
|
166
|
+
/**
|
|
167
|
+
* 다음 회차가 없어 자동화를 끝낼 때만 쓰는 단방향(끄기 전용) write.
|
|
168
|
+
*
|
|
169
|
+
* advanceAfterRun 은 실행 시작 시점의 row 스냅샷을 들고 있는데, 그 사이 사용자가
|
|
170
|
+
* 다른 터미널이나 Desktop 토글로 `automation off` 를 할 수 있다(에이전트 실행은
|
|
171
|
+
* 수 분~수십 분). 스냅샷의 enabled 를 되쓰면 그 끄기가 조용히 되돌아가고
|
|
172
|
+
* ("Disabled: … morning" 을 보고 exit 0 인데도) 다음 예정 시각에 또 발화한다.
|
|
173
|
+
* 그래서 enabled 는 "켜기"로는 절대 쓰지 않고, 종료 조건일 때만 0 으로 내린다.
|
|
174
|
+
*/
|
|
175
|
+
function disableExhausted(db, id) {
|
|
176
|
+
db.prepare("UPDATE automations SET enabled = 0 WHERE id = ?").run(id);
|
|
177
|
+
}
|
|
178
|
+
|
|
145
179
|
/**
|
|
146
180
|
* 실행 후 스케줄 북키핑 — v1 runAutomationOnce 의 성공/실패 분기와 동일:
|
|
147
181
|
* - 성공: run_count 증가. 실패: last_run_at 만 갱신(성공 카운트 오염 금지).
|
|
148
182
|
* - advanceSchedule(데몬 경로)일 때만 next_run_at 전진; 다음 시각이 없으면 비활성화.
|
|
149
183
|
* - run-now(advanceSchedule=false)는 스케줄을 건드리지 않는다(앱 advanceSchedule=false와 동일).
|
|
184
|
+
*
|
|
185
|
+
* enabled 는 여기서 스냅샷 값으로 되쓰지 않는다 — disableExhausted 주석 참조.
|
|
186
|
+
* (같은 이유로 성공/실패 두 분기 모두 같은 규칙을 따라야 한다: 한쪽만 고치면
|
|
187
|
+
* 실패로 끝난 실행이 여전히 끈 자동화를 부활시킨다.)
|
|
150
188
|
*/
|
|
151
189
|
function advanceAfterRun(db, row, { ok, advanceSchedule, ranAt = new Date() } = {}) {
|
|
152
190
|
const hasRunCount = columnExists(db, "automations", "run_count");
|
|
@@ -156,12 +194,13 @@ function advanceAfterRun(db, row, { ok, advanceSchedule, ranAt = new Date() } =
|
|
|
156
194
|
if (shouldAdvance) {
|
|
157
195
|
if (hasRunCount) {
|
|
158
196
|
db.prepare(
|
|
159
|
-
"UPDATE automations SET last_run_at = ?, run_count = run_count + 1, next_run_at =
|
|
160
|
-
).run(ranAt.toISOString(), advance ? advance.toISOString() : null,
|
|
197
|
+
"UPDATE automations SET last_run_at = ?, run_count = run_count + 1, next_run_at = ? WHERE id = ?",
|
|
198
|
+
).run(ranAt.toISOString(), advance ? advance.toISOString() : null, row.id);
|
|
161
199
|
} else {
|
|
162
|
-
db.prepare("UPDATE automations SET last_run_at = ?, next_run_at =
|
|
163
|
-
.run(ranAt.toISOString(), advance ? advance.toISOString() : null,
|
|
200
|
+
db.prepare("UPDATE automations SET last_run_at = ?, next_run_at = ? WHERE id = ?")
|
|
201
|
+
.run(ranAt.toISOString(), advance ? advance.toISOString() : null, row.id);
|
|
164
202
|
}
|
|
203
|
+
if (!advance) disableExhausted(db, row.id);
|
|
165
204
|
} else if (hasRunCount) {
|
|
166
205
|
db.prepare("UPDATE automations SET last_run_at = ?, run_count = run_count + 1 WHERE id = ?")
|
|
167
206
|
.run(ranAt.toISOString(), row.id);
|
|
@@ -170,25 +209,64 @@ function advanceAfterRun(db, row, { ok, advanceSchedule, ranAt = new Date() } =
|
|
|
170
209
|
}
|
|
171
210
|
// max_runs 도달 시 비활성화 (앱과 동일한 종료 조건).
|
|
172
211
|
if (row.max_runs && (row.run_count || 0) + 1 >= row.max_runs) {
|
|
173
|
-
db
|
|
212
|
+
disableExhausted(db, row.id);
|
|
174
213
|
return { advance, maxRunsReached: true };
|
|
175
214
|
}
|
|
176
215
|
} else if (shouldAdvance) {
|
|
177
|
-
db.prepare("UPDATE automations SET last_run_at = ?, next_run_at =
|
|
178
|
-
.run(ranAt.toISOString(), advance ? advance.toISOString() : null,
|
|
216
|
+
db.prepare("UPDATE automations SET last_run_at = ?, next_run_at = ? WHERE id = ?")
|
|
217
|
+
.run(ranAt.toISOString(), advance ? advance.toISOString() : null, row.id);
|
|
218
|
+
if (!advance) disableExhausted(db, row.id);
|
|
179
219
|
} else {
|
|
180
220
|
db.prepare("UPDATE automations SET last_run_at = ? WHERE id = ?").run(ranAt.toISOString(), row.id);
|
|
181
221
|
}
|
|
182
222
|
return { advance, maxRunsReached: false };
|
|
183
223
|
}
|
|
184
224
|
|
|
185
|
-
/**
|
|
186
|
-
|
|
225
|
+
/**
|
|
226
|
+
* 이 실행기가 실행할 수 없는 계열의 SQL 술어 — daemon.runnerSkip 게이트와 짝이다.
|
|
227
|
+
* (hub 타깃 / tool_mode browser·computer-use = Desktop 몫.)
|
|
228
|
+
* IFNULL 필수: SQLite 3치 논리에서 NULL 열이 있으면 NOT (…) 이 NULL 이 되어
|
|
229
|
+
* 멀쩡한 행까지 조용히 사라진다.
|
|
230
|
+
*/
|
|
231
|
+
function runnerUnsupportedSql(db) {
|
|
232
|
+
const parts = ["IFNULL(target_type,'') = 'hub'"];
|
|
233
|
+
if (columnExists(db, "automations", "tool_mode")) {
|
|
234
|
+
parts.push("IFNULL(tool_mode,'') IN ('browser','computer-use')");
|
|
235
|
+
}
|
|
236
|
+
return `(${parts.join(" OR ")})`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 데몬 폴링: 활성 + 스케줄 트리거 + next_run_at 도래분. trigger_type 열은 방어적.
|
|
241
|
+
*
|
|
242
|
+
* opts.runnable=true (데몬 실행 창): 이 실행기가 실행할 수 없는 행을 셀렉션에서 뺀다.
|
|
243
|
+
* 이유(굶주림 근본): 미지원 행(Desktop 몫)과 남이 리스를 쥔 행은 스킵돼도 next_run_at
|
|
244
|
+
* 이 전진하지 않는다 — 시간순 LIMIT n 창의 머리에 영구히 남아 뒤의 실행 가능한
|
|
245
|
+
* 자동화를 전부 굶긴다(데몬은 멀쩡해 보이고 아무것도 안 돈다). 실행 못 할 행은
|
|
246
|
+
* 애초에 창에 담지 않는 것이 유일한 근본 수리다 — 그 행의 회차는 그대로 보존된다.
|
|
247
|
+
* opts.runnable=false: 그 미지원 행만(고지용). 미지정: 예전 그대로 전체.
|
|
248
|
+
*/
|
|
249
|
+
function dueAutomations(db, nowIso = new Date().toISOString(), limit = 5, opts = {}) {
|
|
187
250
|
if (!tableExists(db, "automations")) return [];
|
|
188
|
-
const
|
|
251
|
+
const where = ["enabled = 1"];
|
|
252
|
+
const params = [];
|
|
253
|
+
if (columnExists(db, "automations", "trigger_type")) where.push("trigger_type = 'schedule'");
|
|
254
|
+
where.push("next_run_at IS NOT NULL", "next_run_at <= ?");
|
|
255
|
+
params.push(nowIso);
|
|
256
|
+
if (opts.runnable === true) {
|
|
257
|
+
where.push(`NOT ${runnerUnsupportedSql(db)}`);
|
|
258
|
+
if (leaseSupported(db)) {
|
|
259
|
+
// 남이 유효 리스를 쥔 행은 claimAutomation 이 반드시 실패한다 — 같은 굶주림 경로.
|
|
260
|
+
where.push("(claimed_at IS NULL OR claimed_at < ?)");
|
|
261
|
+
params.push(new Date(Date.parse(nowIso) - LEASE_TTL_MS).toISOString());
|
|
262
|
+
}
|
|
263
|
+
} else if (opts.runnable === false) {
|
|
264
|
+
where.push(runnerUnsupportedSql(db));
|
|
265
|
+
}
|
|
266
|
+
params.push(limit);
|
|
189
267
|
return db.prepare(
|
|
190
|
-
`SELECT * FROM automations WHERE
|
|
191
|
-
).all(
|
|
268
|
+
`SELECT * FROM automations WHERE ${where.join(" AND ")} ORDER BY next_run_at ASC LIMIT ?`,
|
|
269
|
+
).all(...params);
|
|
192
270
|
}
|
|
193
271
|
|
|
194
272
|
module.exports = {
|
|
@@ -201,9 +279,11 @@ module.exports = {
|
|
|
201
279
|
setEnabled,
|
|
202
280
|
removeAutomation,
|
|
203
281
|
claimAutomation,
|
|
282
|
+
renewAutomationLease,
|
|
204
283
|
releaseAutomation,
|
|
205
284
|
recordRun,
|
|
206
285
|
listRuns,
|
|
207
286
|
advanceAfterRun,
|
|
208
287
|
dueAutomations,
|
|
288
|
+
runnerUnsupportedSql,
|
|
209
289
|
};
|