agentlas 1.0.65 → 1.0.67

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.
Files changed (163) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +12 -4
  3. package/bin/agentlas.cjs +354 -16
  4. package/engine/acp/server.cjs +34 -3
  5. package/engine/agentlas-api-agent.cjs +151 -120
  6. package/engine/agentlas-banner.cjs +6 -2
  7. package/engine/agentlas-capabilities.cjs +17 -10
  8. package/engine/agentlas-cloud-runtime.cjs +4 -5
  9. package/engine/agentlas-config.cjs +19 -54
  10. package/engine/agentlas-core-harness.cjs +59 -5
  11. package/engine/agentlas-desktop-loadout.cjs +17 -3
  12. package/engine/agentlas-evolution.cjs +75 -18
  13. package/engine/agentlas-experience-exchange.cjs +145 -14
  14. package/engine/agentlas-experience-intake.cjs +2 -2
  15. package/engine/agentlas-i18n.cjs +0 -2
  16. package/engine/agentlas-input.cjs +273 -20
  17. package/engine/agentlas-judgment.cjs +0 -0
  18. package/engine/agentlas-mcp-env.cjs +84 -23
  19. package/engine/agentlas-mcp-wrapper.cjs +36 -4
  20. package/engine/agentlas-memory-governance.cjs +2 -1
  21. package/engine/agentlas-memory-import.cjs +49 -12
  22. package/engine/agentlas-native-host.cjs +279 -44
  23. package/engine/agentlas-onboard.cjs +23 -1
  24. package/engine/agentlas-permissions.cjs +5 -1
  25. package/engine/agentlas-sqlite-policy.cjs +40 -2
  26. package/engine/agentlas-tasks.cjs +7 -0
  27. package/engine/agentlas-tools.cjs +37 -10
  28. package/engine/agentlas-ui.cjs +26 -4
  29. package/engine/agentlas-workforce.cjs +30 -5
  30. package/engine/agentlas-workload-routing.cjs +83 -10
  31. package/engine/agentlas.cjs +21 -4
  32. package/engine/agents/builder.cjs +35 -10
  33. package/engine/agents/files.cjs +81 -10
  34. package/engine/agents/import-local.cjs +10 -2
  35. package/engine/agents/registry.cjs +10 -3
  36. package/engine/agents/router.cjs +7 -7
  37. package/engine/agents/routes.cjs +31 -9
  38. package/engine/automation/daemon.cjs +154 -40
  39. package/engine/automation/launchd.cjs +395 -16
  40. package/engine/automation/schedule.cjs +90 -3
  41. package/engine/automation/store.cjs +163 -59
  42. package/engine/browser/cdp.cjs +190 -17
  43. package/engine/browser/vault.cjs +1 -0
  44. package/engine/cli-output.cjs +96 -30
  45. package/engine/cloud/auth.cjs +131 -13
  46. package/engine/cloud/hub-client.cjs +41 -16
  47. package/engine/cloud-assets/cas.cjs +18 -9
  48. package/engine/cloud-assets/commands.cjs +108 -30
  49. package/engine/cloud-assets/package.cjs +66 -3
  50. package/engine/cloud-assets/restore.cjs +12 -9
  51. package/engine/cloud-assets/state.cjs +609 -116
  52. package/engine/commands/acp.cjs +13 -2
  53. package/engine/commands/automation.cjs +75 -24
  54. package/engine/commands/billing.cjs +6 -1
  55. package/engine/commands/browser.cjs +61 -29
  56. package/engine/commands/build.cjs +72 -14
  57. package/engine/commands/call.cjs +1 -1
  58. package/engine/commands/cd.cjs +1 -1
  59. package/engine/commands/cloud.cjs +2 -1
  60. package/engine/commands/connect.cjs +38 -13
  61. package/engine/commands/creds.cjs +74 -29
  62. package/engine/commands/doctor.cjs +17 -1
  63. package/engine/commands/document.cjs +38 -10
  64. package/engine/commands/env.cjs +6 -1
  65. package/engine/commands/firm.cjs +42 -12
  66. package/engine/commands/graph.cjs +36 -32
  67. package/engine/commands/help.cjs +11 -1
  68. package/engine/commands/hep.cjs +1 -1
  69. package/engine/commands/import.cjs +5 -4
  70. package/engine/commands/index.cjs +1 -1
  71. package/engine/commands/install.cjs +6 -4
  72. package/engine/commands/list.cjs +56 -16
  73. package/engine/commands/login.cjs +5 -0
  74. package/engine/commands/logout.cjs +6 -1
  75. package/engine/commands/mcp.cjs +150 -37
  76. package/engine/commands/memory.cjs +3 -2
  77. package/engine/commands/multimodal.cjs +12 -0
  78. package/engine/commands/native.cjs +5 -1
  79. package/engine/commands/one.cjs +78 -33
  80. package/engine/commands/plugin.cjs +13 -2
  81. package/engine/commands/project.cjs +15 -12
  82. package/engine/commands/research.cjs +18 -7
  83. package/engine/commands/roles.cjs +104 -28
  84. package/engine/commands/route.cjs +2 -2
  85. package/engine/commands/run.cjs +42 -9
  86. package/engine/commands/search.cjs +23 -2
  87. package/engine/commands/setup.cjs +6 -1
  88. package/engine/commands/storm.cjs +44 -6
  89. package/engine/commands/swarm.cjs +47 -9
  90. package/engine/commands/telegram.cjs +6 -1
  91. package/engine/commands/uninstall.cjs +12 -6
  92. package/engine/commands/update.cjs +5 -0
  93. package/engine/commands/upload.cjs +2 -1
  94. package/engine/commands/usage.cjs +6 -1
  95. package/engine/commands/version.cjs +6 -1
  96. package/engine/commands/whoami.cjs +6 -1
  97. package/engine/commands/workforce.cjs +60 -16
  98. package/engine/core/capability-grants.cjs +7 -2
  99. package/engine/core/daemon-client.cjs +36 -7
  100. package/engine/core/db.cjs +10 -2
  101. package/engine/core/desktop-core-fetch.cjs +477 -77
  102. package/engine/core/schema-ensure.cjs +23 -11
  103. package/engine/experience/build.cjs +5 -1
  104. package/engine/experience/intents.cjs +35 -82
  105. package/engine/experience/runtime.cjs +7 -1
  106. package/engine/experience/variant.cjs +58 -4
  107. package/engine/firms/orchestrate.cjs +9 -6
  108. package/engine/graph/ask-model.cjs +22 -11
  109. package/engine/graph/interview.cjs +74 -23
  110. package/engine/graph/package.cjs +63 -8
  111. package/engine/hephaestus/local-core.cjs +18 -5
  112. package/engine/hephaestus/runtime.cjs +48 -43
  113. package/engine/hub/install.cjs +484 -63
  114. package/engine/hub/plugins.cjs +391 -29
  115. package/engine/mcp/consent.cjs +25 -17
  116. package/engine/mcp/contract.cjs +229 -24
  117. package/engine/mcp/inventory.cjs +46 -10
  118. package/engine/mcp/plan.cjs +10 -1
  119. package/engine/mcp/probe.cjs +24 -9
  120. package/engine/memory-cli/curate.cjs +74 -16
  121. package/engine/oberon/common.cjs +37 -12
  122. package/engine/oberon/manifest.cjs +25 -2
  123. package/engine/oberon/outputs.cjs +49 -19
  124. package/engine/oberon/render.cjs +665 -40
  125. package/engine/project/career-graph.cjs +15 -2
  126. package/engine/project/controller.cjs +133 -15
  127. package/engine/project/credentials.cjs +382 -34
  128. package/engine/project/env-file.cjs +93 -8
  129. package/engine/project/memory-context.cjs +11 -57
  130. package/engine/project/ontology.cjs +66 -25
  131. package/engine/project/paths.cjs +42 -8
  132. package/engine/project/seed.cjs +88 -51
  133. package/engine/project/state.cjs +41 -16
  134. package/engine/project/team.cjs +26 -1
  135. package/engine/runtime-refusal.cjs +4 -1
  136. package/engine/runtimes/acp-driver.cjs +56 -9
  137. package/engine/runtimes/auth-evidence.cjs +21 -2
  138. package/engine/runtimes/detect.cjs +28 -8
  139. package/engine/runtimes/kinds.cjs +12 -4
  140. package/engine/runtimes/overrides.cjs +4 -3
  141. package/engine/runtimes/resolve.cjs +8 -7
  142. package/engine/runtimes/roles.cjs +7 -5
  143. package/engine/sessions/apply-fences.cjs +396 -56
  144. package/engine/sessions/fences.cjs +111 -27
  145. package/engine/sessions/memory-turn.cjs +62 -3
  146. package/engine/sessions/orchestrator.cjs +11 -1
  147. package/engine/sessions/prompt.cjs +66 -9
  148. package/engine/sessions/session.cjs +70 -10
  149. package/engine/sessions/sink.cjs +78 -12
  150. package/engine/sessions/store.cjs +29 -7
  151. package/engine/storm/storm.cjs +26 -3
  152. package/engine/storm/swarm.cjs +31 -3
  153. package/engine/telegram/connect.cjs +185 -22
  154. package/engine/ui/commands-catalog.cjs +1 -1
  155. package/engine/ui/palette.cjs +12 -2
  156. package/engine/ui/repl.cjs +57 -9
  157. package/engine/ui/screens.cjs +2 -4
  158. package/engine/vendor/desktop-core.manifest.json +5 -5
  159. package/engine/workforce/capture.cjs +9 -27
  160. package/engine/workforce/deps.cjs +18 -6
  161. package/engine/workforce/local-core-transport.cjs +68 -5
  162. package/package.json +1 -1
  163. package/engine/commands/career-graph.cjs +0 -51
@@ -122,9 +122,16 @@ function findAgent(db, token) {
122
122
  const q = String(token || "").trim().toLowerCase();
123
123
  if (!q) return null;
124
124
  const agents = listRoutableAgents(db);
125
- const exact = agents.find((a) => a.slug.toLowerCase() === q || a.id === token)
126
- || agents.find((a) => (a.name || "").toLowerCase() === q || (a.nameEn || "").toLowerCase() === q);
127
- if (exact) return exact;
125
+ const exactId = agents.find((agent) => agent.id === token);
126
+ if (exactId) return exactId; // primary key is authoritative
127
+ const exactSlug = agents.filter((agent) => agent.slug.toLowerCase() === q);
128
+ if (exactSlug.length === 1) return exactSlug[0];
129
+ if (exactSlug.length > 1) return null;
130
+ const exactName = agents.filter((agent) =>
131
+ (agent.name || "").toLowerCase() === q || (agent.nameEn || "").toLowerCase() === q,
132
+ );
133
+ if (exactName.length === 1) return exactName[0];
134
+ if (exactName.length > 1) return null;
128
135
  const partial = agents.filter((a) =>
129
136
  a.slug.toLowerCase().includes(q) || (a.name || "").toLowerCase().includes(q) || (a.nameEn || "").toLowerCase().includes(q));
130
137
  if (partial.length === 1) return partial[0];
@@ -72,11 +72,11 @@ function ensureJudgeRunner(db, runtime) {
72
72
  const fetchImpl = typeof baseFetch === "function" && signal
73
73
  ? (url, init) => baseFetch(url, { ...(init || {}), signal })
74
74
  : baseFetch;
75
- try {
76
- return await capture.runApi(backend, resolved.model || null, system, prompt, { fetch: fetchImpl });
77
- } catch {
78
- return "";
79
- }
75
+ // CLI 판정과 같은 정직한 실패 계약을 쓴다. 여기서 API 오류를 빈 문자열로
76
+ // 바꾸면 judgment 서비스가 인증·한도·연결 사유를 보존할 기회를 잃고
77
+ // "유효한 판단 없음"으로 오진한다. judgeLabels가 예외 문구를 bounded reason으로
78
+ // 정규화하므로 원래 오류를 그대로 올린다.
79
+ return capture.runApi(backend, resolved.model || null, system, prompt, { fetch: fetchImpl });
80
80
  });
81
81
  return judgment;
82
82
  }
@@ -158,9 +158,9 @@ async function resolveAutoRoute(db, task, opts = {}) {
158
158
 
159
159
  if (verdict.source !== "llm" || !verdict.labels.length) {
160
160
  return finish(unresolvedChoice(lang,
161
- lang === "ko"
161
+ verdict.reason || (lang === "ko"
162
162
  ? "연결된 모델이 유효한 판단을 반환하지 않았습니다"
163
- : "The connected model did not return a valid judgment",
163
+ : "The connected model did not return a valid judgment"),
164
164
  "model_unavailable"));
165
165
  }
166
166
  const picked = verdict.labels[0];
@@ -5,6 +5,7 @@
5
5
  */
6
6
  const fs = require("node:fs");
7
7
  const path = require("node:path");
8
+ const { randomUUID } = require("node:crypto");
8
9
  const { userDataDir } = require("../core/paths.cjs");
9
10
 
10
11
  function routesPath() {
@@ -14,26 +15,47 @@ function routesPath() {
14
15
  function routesMap() {
15
16
  try {
16
17
  const parsed = JSON.parse(fs.readFileSync(routesPath(), "utf8"));
17
- return parsed && typeof parsed === "object" ? parsed : {};
18
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return Object.create(null);
19
+ return Object.assign(Object.create(null), parsed);
18
20
  } catch {
19
- return {};
21
+ return Object.create(null);
20
22
  }
21
23
  }
22
24
 
23
25
  /** 원자적 사설 쓰기 (0600) — 데스크탑과 파일을 공유하므로 부분 쓰기가 보이면 안 된다. */
24
26
  function saveRoutes(routes) {
25
27
  const file = routesPath();
26
- fs.mkdirSync(path.dirname(file), { recursive: true });
27
- const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
28
- const fd = fs.openSync(tmp, "wx", 0o600);
28
+ const parent = path.dirname(file);
29
+ fs.mkdirSync(parent, { recursive: true });
30
+ const tmp = path.join(parent, `.${path.basename(file)}.${randomUUID()}.tmp`);
31
+ let fd = null;
29
32
  try {
30
- fs.writeFileSync(fd, JSON.stringify(routes, null, 2), "utf8");
33
+ fd = fs.openSync(
34
+ tmp,
35
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
36
+ 0o600,
37
+ );
38
+ fs.writeFileSync(fd, JSON.stringify(routes, null, 2) + "\n", "utf8");
31
39
  fs.fsyncSync(fd);
32
- } finally {
33
40
  fs.closeSync(fd);
41
+ fd = null;
42
+ fs.renameSync(tmp, file);
43
+ fsyncDirectoryBestEffort(parent);
44
+ } finally {
45
+ if (fd !== null) fs.closeSync(fd);
46
+ try { fs.unlinkSync(tmp); } catch { /* rename consumed it, or cleanup is best-effort */ }
47
+ }
48
+ }
49
+
50
+ function fsyncDirectoryBestEffort(directory) {
51
+ try {
52
+ const fd = fs.openSync(directory, fs.constants.O_RDONLY);
53
+ try { fs.fsyncSync(fd); }
54
+ finally { fs.closeSync(fd); }
55
+ } catch {
56
+ // Some filesystems do not support directory fsync. The same-directory
57
+ // rename still keeps readers on either the complete old or complete new map.
34
58
  }
35
- fs.renameSync(tmp, file);
36
- try { fs.chmodSync(file, 0o600); } catch { /* win32 */ }
37
59
  }
38
60
 
39
61
  function routeForAgent(agentId) {
@@ -11,6 +11,8 @@
11
11
  * 리스를 못 잡으면 실행하지 않는다(skip, 정직 보고).
12
12
  */
13
13
  const crypto = require("node:crypto");
14
+ const fs = require("node:fs");
15
+ const path = require("node:path");
14
16
  const { findAgent, rowToAgent } = require("../agents/registry.cjs");
15
17
  const { resolveRuntime } = require("../runtimes/resolve.cjs");
16
18
  const { Orchestrator } = require("../sessions/orchestrator.cjs");
@@ -24,7 +26,11 @@ const store = require("./store.cjs");
24
26
  // getAutomationExecutionContractState 동형) ──────────────────────────────
25
27
  // 손상된/미래 계약 값은 절대 조용히 넓혀 실행하지 않는다 — raw-row 게이트로
26
28
  // 무인 실행 직전에 검사한다(데스크탑 automation-scheduler.ts:538-549).
27
- const { CONTRACT_RUNTIME_KINDS, CONTRACT_RUNTIME_BACKENDS } = require("../runtimes/kinds.cjs");
29
+ const {
30
+ CONTRACT_RUNTIME_KINDS,
31
+ CONTRACT_RUNTIME_BACKENDS,
32
+ canonicalRuntimeKind,
33
+ } = require("../runtimes/kinds.cjs");
28
34
  const RUNTIME_KINDS = new Set(CONTRACT_RUNTIME_KINDS);
29
35
  const RUNTIME_BACKENDS = new Set(CONTRACT_RUNTIME_BACKENDS);
30
36
  const RUNTIME_SELECTION_KEYS = new Set(["kind", "backend", "source", "model", "longContext", "effort"]);
@@ -33,22 +39,86 @@ function decodeRuntimeSelection(raw) {
33
39
  if (raw == null) return { state: "missing" };
34
40
  try {
35
41
  const value = JSON.parse(raw);
42
+ // Desktop의 저장 디코더와 같은 레거시 보정. 예전 kind=gemini 행은 현재
43
+ // Antigravity 선택으로 간주하되, 당시 검증되지 않은 source/model은 버린다.
44
+ const normalized = value && value.kind === "gemini"
45
+ ? { ...value, kind: "antigravity", source: undefined, model: undefined }
46
+ : value;
36
47
  if (
37
- value && typeof value === "object" && !Array.isArray(value) &&
38
- Object.keys(value).every((key) => RUNTIME_SELECTION_KEYS.has(key)) &&
39
- typeof value.kind === "string" && RUNTIME_KINDS.has(value.kind) &&
40
- (value.backend === undefined || (typeof value.backend === "string" && RUNTIME_BACKENDS.has(value.backend))) &&
41
- (value.source === undefined || (typeof value.source === "string" && value.source.length > 0 && value.source.length <= 2048)) &&
42
- (value.model === undefined || (typeof value.model === "string" && value.model.length > 0 && value.model.length <= 512)) &&
43
- (value.longContext === undefined || typeof value.longContext === "boolean") &&
44
- (value.effort === undefined || (typeof value.effort === "string" && value.effort.length <= 128))
48
+ normalized && typeof normalized === "object" && !Array.isArray(normalized) &&
49
+ Object.keys(normalized).every((key) => RUNTIME_SELECTION_KEYS.has(key)) &&
50
+ typeof normalized.kind === "string" && RUNTIME_KINDS.has(normalized.kind) &&
51
+ (normalized.backend === undefined || (typeof normalized.backend === "string" && RUNTIME_BACKENDS.has(normalized.backend))) &&
52
+ (normalized.source === undefined || (typeof normalized.source === "string" && normalized.source.length > 0 && normalized.source.length <= 2048)) &&
53
+ (normalized.model === undefined || (typeof normalized.model === "string" && normalized.model.length > 0 && normalized.model.length <= 512)) &&
54
+ (normalized.longContext === undefined || typeof normalized.longContext === "boolean") &&
55
+ (normalized.effort === undefined || (typeof normalized.effort === "string" && normalized.effort.length <= 128))
45
56
  ) {
46
- return { state: "valid", value };
57
+ return {
58
+ state: "valid",
59
+ value: {
60
+ ...normalized,
61
+ storedKind: normalized.kind,
62
+ kind: canonicalRuntimeKind(normalized.kind),
63
+ },
64
+ };
47
65
  }
48
66
  } catch { /* 손상 데이터는 아래에서 invalid — 진짜 없는 레거시 핀과 구분한다 */ }
49
67
  return { state: "invalid" };
50
68
  }
51
69
 
70
+ const PIN_RUNTIME_IDENTITIES = Object.freeze({
71
+ "claude-code": { backend: "anthropic", source: "claude" },
72
+ codex: { backend: "openai", source: "codex" },
73
+ agy: { backend: "google", source: "agy" },
74
+ kimi: { backend: "kimi", source: "kimi" },
75
+ grok: { backend: "custom", source: "grok" },
76
+ cursor: { backend: "cursor", source: "cursor-agent" },
77
+ ollama: { backend: "ollama", source: "ollama" },
78
+ });
79
+
80
+ function sameExecutableSource(source, resolvedBin, expectedName) {
81
+ if (!source) return true;
82
+ const requested = String(source);
83
+ if (!resolvedBin) return requested === expectedName;
84
+ const requestedBase = path.basename(requested);
85
+ const resolvedBase = path.basename(String(resolvedBin));
86
+ if (requestedBase !== resolvedBase || requestedBase !== expectedName) return false;
87
+ if (!path.isAbsolute(requested)) return true;
88
+ try {
89
+ return fs.realpathSync(requested) === fs.realpathSync(String(resolvedBin));
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ function materializePinnedRuntime(pin, resolver = resolveRuntime, context = {}) {
96
+ const label = pin.storedKind || pin.kind;
97
+ const unavailable = () => new Error(
98
+ `Pinned automation runtime is unavailable: ${label}${pin.model ? ` · ${pin.model}` : ""}`,
99
+ );
100
+ let runtime;
101
+ try {
102
+ runtime = resolver({ db: context.db, prefs: context.prefs, explicit: pin.kind });
103
+ } catch {
104
+ throw unavailable();
105
+ }
106
+ const identity = PIN_RUNTIME_IDENTITIES[pin.kind];
107
+ if (pin.backend && (!identity || pin.backend !== identity.backend)) throw unavailable();
108
+ if (pin.source && (!identity || !sameExecutableSource(pin.source, runtime.bin, identity.source))) {
109
+ throw unavailable();
110
+ }
111
+ return {
112
+ ...runtime,
113
+ ...(identity ? { backend: identity.backend, runtimeSource: pin.source || runtime.bin || identity.source } : {}),
114
+ ...(pin.model ? { model: pin.model } : {}),
115
+ ...(pin.effort ? { effort: pin.effort } : {}),
116
+ ...(pin.longContext !== undefined ? { longContext: pin.longContext } : {}),
117
+ source: "automation-pin",
118
+ pinAuthoritative: true,
119
+ };
120
+ }
121
+
52
122
  function automationContractState(db, row) {
53
123
  const runtimeSelection = columnExists(db, "automations", "runtime_selection_json")
54
124
  ? decodeRuntimeSelection(row.runtime_selection_json)
@@ -143,7 +213,23 @@ function runnerSkip(db, row, ko) {
143
213
  }
144
214
 
145
215
  /**
146
- * 자동화 1건 실행(헤드리스). 권한은 자동화 행의 permission 열(있으면), 없으면 "write".
216
+ * Automation permission is data, not a default capability. New stores use
217
+ * execution_permission; older stores use permission. If neither column is
218
+ * present, or either value is corrupt/empty, agentlas-permissions deliberately
219
+ * returns read so a damaged row cannot become a write-capable background run.
220
+ */
221
+ function automationPermission(db, row) {
222
+ const raw = columnExists(db, "automations", "execution_permission")
223
+ ? row.execution_permission
224
+ : columnExists(db, "automations", "permission")
225
+ ? row.permission
226
+ : undefined;
227
+ return permissions.normalize(raw);
228
+ }
229
+
230
+ /**
231
+ * 자동화 1건 실행(헤드리스). 권한은 execution_permission/permission 열에서만
232
+ * 읽으며, 누락·손상 값은 agentlas-permissions를 거쳐 read로 fail-closed 한다.
147
233
  * opts:
148
234
  * advanceSchedule 데몬 경로 true / run-now false (v1과 동일)
149
235
  * scheduledFor 기록용 예정 시각(ISO)
@@ -175,7 +261,8 @@ async function runAutomationOnce(ctx, db, row, opts = {}) {
175
261
  return { ok: false, skipped: true, reason: "lease-unsupported" };
176
262
  }
177
263
  // run-now도 리스를 잡는다 — 앱 스케줄러가 같은 행을 동시에 돌리는 것을 방지.
178
- if (!store.claimAutomation(db, row.id)) {
264
+ const lease = store.claimAutomationLease(db, row.id);
265
+ if (!lease) {
179
266
  ctx.err(ko
180
267
  ? `다른 실행기가 이 자동화 리스를 보유 중입니다(15분 TTL): ${row.name}`
181
268
  : `Another runner holds this automation (lease TTL 15 minutes): ${row.name}`);
@@ -190,11 +277,13 @@ async function runAutomationOnce(ctx, db, row, opts = {}) {
190
277
  let leaseRenewWarningEmitted = false;
191
278
  const leaseHeartbeat = setInterval(() => {
192
279
  try {
193
- const renewed = store.renewAutomationLease(db, row.id);
280
+ const renewed = store.renewAutomationLeaseToken(db, row.id, new Date(), lease);
194
281
  if (!renewed) {
195
282
  leaseOwnershipLost = true;
196
283
  if (activeSession && typeof activeSession.kill === "function") activeSession.kill();
197
284
  } else {
285
+ lease.owner = renewed.owner;
286
+ lease.claimedAt = renewed.claimedAt;
198
287
  leaseRenewWarningEmitted = false;
199
288
  }
200
289
  } catch (error) {
@@ -211,6 +300,31 @@ async function runAutomationOnce(ctx, db, row, opts = {}) {
211
300
  }, Math.max(10, Number(opts.leaseHeartbeatMs) || 60_000));
212
301
  if (typeof leaseHeartbeat.unref === "function") leaseHeartbeat.unref();
213
302
 
303
+ const leaseLostResult = () => ({
304
+ ok: false,
305
+ skipped: true,
306
+ reason: "lease-lost",
307
+ error: "Automation execution lease ownership lost",
308
+ });
309
+ const completeRun = (status, error, ok) => {
310
+ const recorded = store.recordRun(db, row.id, status, error, opts.scheduledFor, lease);
311
+ if (recorded && recorded.leaseLost) return { leaseLost: true };
312
+ const advanced = store.advanceAfterRun(db, row, {
313
+ ok,
314
+ advanceSchedule: !!opts.advanceSchedule,
315
+ lease,
316
+ });
317
+ if (advanced && advanced.leaseLost) return { leaseLost: true };
318
+ if (!advanced || !advanced.updated) {
319
+ return {
320
+ bookkeepingError: advanced && advanced.error
321
+ ? advanced.error
322
+ : "automation completion state was not updated",
323
+ };
324
+ }
325
+ return { recorded, advanced };
326
+ };
327
+
214
328
  try {
215
329
  // ── raw-row 실행 계약 게이트 (데스크탑 automation-scheduler.ts:538-549 동형) ──
216
330
  // 손상된 계약 값으로는 무인 실행하지 않는다 — 문구까지 데스크탑과 동일.
@@ -218,15 +332,17 @@ async function runAutomationOnce(ctx, db, row, opts = {}) {
218
332
  if (contract.runtimeSelection === "invalid") {
219
333
  const msg = "pinned_runtime_contract_invalid: the saved runtime pin is malformed and requires an explicit runtime selection.";
220
334
  ctx.err(msg);
221
- store.recordRun(db, row.id, "needs_input", msg, opts.scheduledFor);
222
- store.advanceAfterRun(db, row, { ok: false, advanceSchedule: !!opts.advanceSchedule });
335
+ const completed = completeRun("needs_input", msg, false);
336
+ if (completed.leaseLost) return leaseLostResult();
337
+ if (completed.bookkeepingError) ctx.err(completed.bookkeepingError);
223
338
  return { ok: false, error: msg };
224
339
  }
225
340
  if (contract.hubMode === "invalid") {
226
341
  const msg = "automation_hub_mode_contract_invalid: the saved Hub routing policy is unknown and requires an explicit selection.";
227
342
  ctx.err(msg);
228
- store.recordRun(db, row.id, "needs_input", msg, opts.scheduledFor);
229
- store.advanceAfterRun(db, row, { ok: false, advanceSchedule: !!opts.advanceSchedule });
343
+ const completed = completeRun("needs_input", msg, false);
344
+ if (completed.leaseLost) return leaseLostResult();
345
+ if (completed.bookkeepingError) ctx.err(completed.bookkeepingError);
230
346
  return { ok: false, error: msg };
231
347
  }
232
348
 
@@ -237,17 +353,10 @@ async function runAutomationOnce(ctx, db, row, opts = {}) {
237
353
  let runtime = opts.runtime || null;
238
354
  if (!runtime && !opts.runtimeOverride && contract.runtimePin) {
239
355
  const pin = contract.runtimePin;
240
- try {
241
- runtime = resolveRuntime({ db, prefs: ctx.prefs, explicit: pin.kind });
242
- if (pin.model) runtime.model = pin.model;
243
- } catch {
244
- throw new Error(`Pinned automation runtime is unavailable: ${pin.kind}${pin.model ? ` · ${pin.model}` : ""}`);
245
- }
356
+ runtime = materializePinnedRuntime(pin, opts.resolveRuntime || resolveRuntime, { db, prefs: ctx.prefs });
246
357
  }
247
358
  if (!runtime) runtime = resolveRuntime({ db, prefs: ctx.prefs, explicit: opts.runtimeOverride || null });
248
- // 권한: 자동화 행에 permission 열이 있으면 그 값, 없으면 write (v1 기본과 동일).
249
- const rowPermission = columnExists(db, "automations", "permission") ? row.permission : null;
250
- const permission = permissions.normalize(rowPermission || "write", "write");
359
+ const permission = automationPermission(db, row);
251
360
 
252
361
  ctx.err(ctx.ui.dim(`${agent.slug} · ${runtime.kind} · ${permission} · ${ko ? "자동화" : "automation"} ${String(row.id).slice(0, 8)}`));
253
362
 
@@ -281,14 +390,17 @@ async function runAutomationOnce(ctx, db, row, opts = {}) {
281
390
  const failed = session.status === "failed";
282
391
  const errMsg = failed ? String(session.lastError || "runtime turn failed").slice(0, 500) : null;
283
392
 
284
- if (failed) {
285
- ctx.err(errMsg);
286
- store.recordRun(db, row.id, "error", errMsg, opts.scheduledFor);
287
- } else {
288
- store.recordRun(db, row.id, "ok", null, opts.scheduledFor);
393
+ if (failed) ctx.err(errMsg);
394
+ const completed = completeRun(failed ? "error" : "ok", failed ? errMsg : null, !failed);
395
+ if (completed.leaseLost) {
396
+ ctx.err("Automation execution lease ownership lost");
397
+ return leaseLostResult();
289
398
  }
290
- const after = store.advanceAfterRun(db, row, { ok: !failed, advanceSchedule: !!opts.advanceSchedule });
291
- if (after.maxRunsReached) {
399
+ if (completed.bookkeepingError) {
400
+ ctx.err(completed.bookkeepingError);
401
+ return { ok: false, error: completed.bookkeepingError };
402
+ }
403
+ if (completed.advanced.maxRunsReached) {
292
404
  ctx.err(ko ? "max_runs 도달 — 자동화를 비활성화했습니다." : "max_runs reached — automation disabled.");
293
405
  }
294
406
  return failed ? { ok: false, error: errMsg } : { ok: true, finalText };
@@ -298,12 +410,13 @@ async function runAutomationOnce(ctx, db, row, opts = {}) {
298
410
  if (leaseOwnershipLost) {
299
411
  return { ok: false, skipped: true, reason: "lease-lost", error: "Automation execution lease ownership lost" };
300
412
  }
301
- store.recordRun(db, row.id, "error", msg, opts.scheduledFor);
302
- store.advanceAfterRun(db, row, { ok: false, advanceSchedule: !!opts.advanceSchedule });
413
+ const completed = completeRun("error", msg, false);
414
+ if (completed.leaseLost) return leaseLostResult();
415
+ if (completed.bookkeepingError) ctx.err(completed.bookkeepingError);
303
416
  return { ok: false, error: msg };
304
417
  } finally {
305
418
  clearInterval(leaseHeartbeat);
306
- store.releaseAutomation(db, row.id, store.LEASE_OWNER);
419
+ store.releaseAutomation(db, row.id, lease.owner, lease.claimedAt);
307
420
  }
308
421
  }
309
422
 
@@ -360,10 +473,8 @@ async function daemonTick(ctx, db, opts = {}) {
360
473
  ) opts.skipAnnounced.add(row.id);
361
474
  continue;
362
475
  }
363
- // 스케줄이 없는(1회성) 행이 남으면 재발화 방지 (v1과 동일).
364
- if (!row.schedule || !schedule.nextAutomationRun(row)) {
365
- db.prepare("UPDATE automations SET enabled = 0 WHERE id = ? AND (schedule IS NULL OR schedule = '')").run(row.id);
366
- }
476
+ // 1회성 종료는 advanceAfterRun 동일 lease fence 안에서 처리한다.
477
+ // 여기서 lease 해제 후 row id만 다시 쓰면 successor 실행을 끌 수 있다.
367
478
  }
368
479
  return ran;
369
480
  }
@@ -406,5 +517,8 @@ module.exports = {
406
517
  automationContractState,
407
518
  automationSessionChatId,
408
519
  runnerSkip,
520
+ automationPermission,
409
521
  decodeRuntimeSelection,
522
+ materializePinnedRuntime,
523
+ sameExecutableSource,
410
524
  };