agentlas 1.0.61 → 1.0.62
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 +18 -1
- package/engine/acp/server.cjs +47 -17
- package/engine/agentlas-evolution.cjs +85 -36
- package/engine/agentlas-experience-intake.cjs +21 -8
- package/engine/agentlas.cjs +6 -3
- package/engine/automation/daemon.cjs +36 -3
- package/engine/automation/store.cjs +13 -11
- package/engine/bootstrap-schema.sql +30 -2
- package/engine/cloud/auth.cjs +34 -8
- package/engine/commands/connect.cjs +34 -6
- package/engine/commands/oberon.cjs +1 -1
- package/engine/commands/update.cjs +25 -4
- package/engine/core/desktop-core-fetch.cjs +94 -21
- package/engine/hephaestus/local-core.cjs +44 -9
- package/engine/memory-cli/curate.cjs +5 -2
- package/engine/oberon/outputs.cjs +10 -3
- package/engine/project/career-graph.cjs +4 -2
- package/engine/project/memory-context.cjs +9 -6
- package/engine/project/ontology.cjs +17 -7
- package/engine/runtimes/acp-driver.cjs +42 -3
- package/engine/sessions/session.cjs +18 -0
- package/engine/telegram/connect.cjs +31 -10
- package/engine/ui/screens.cjs +30 -6
- package/engine/vendor/desktop-core.manifest.json +5 -5
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 1.0.62 — 2026-08-29
|
|
4
|
+
|
|
5
|
+
- Terminal sessions now preserve ACP conversation identity and history across
|
|
6
|
+
editor turns, propagate cancellation and consented MCP configuration to
|
|
7
|
+
Cursor/Grok/Kimi, and reject overlapping prompts instead of cross-streaming
|
|
8
|
+
two JSON-RPC requests.
|
|
9
|
+
- Automation leases, Telegram pairing, cloud session storage, Local Core
|
|
10
|
+
transport, evolution writes, experience events, project path matching, and
|
|
11
|
+
memory ownership now fail closed under races and partial failures.
|
|
12
|
+
- The on-demand Desktop core cache is bound to the exact release SHA, includes
|
|
13
|
+
every built-in plugin runtime package, and is published as the audited
|
|
14
|
+
`desktop-core-v15` asset without dependency tests, fixtures, or local paths.
|
|
15
|
+
- npm packages exclude the generated Desktop core tree and tarball, shrinking
|
|
16
|
+
the install artifact from roughly 36 MB to under 1 MB while retaining the
|
|
17
|
+
checksummed v15 fetch manifest. The publication workflow enforces this
|
|
18
|
+
boundary.
|
|
19
|
+
- `agentlas update` now bounds registry connection and body parsing so a stalled
|
|
20
|
+
npm request cannot hang the CLI indefinitely.
|
|
4
21
|
|
|
5
22
|
- `agentlas acp` — Agentlas as an Agent Client Protocol agent (Phase B-3). Zed,
|
|
6
23
|
JetBrains IDEs and other ACP clients can run the project controller (or an
|
package/engine/acp/server.cjs
CHANGED
|
@@ -50,8 +50,20 @@ function textOfPrompt(blocks) {
|
|
|
50
50
|
* events: { onDelta(text), onTool(name, summary, id), onToolResult(text, ok, id), onStatus(text) }
|
|
51
51
|
* cancel(sessionKey) → void
|
|
52
52
|
*/
|
|
53
|
-
function productionTurnRunner() {
|
|
54
|
-
const sessions = new Map(); // acp sessionId →
|
|
53
|
+
function productionTurnRunner(options = {}) {
|
|
54
|
+
const sessions = new Map(); // currently running acp sessionId → Session
|
|
55
|
+
const createSession = options.createSession || ((ctx, spec, prompt) => {
|
|
56
|
+
const { Orchestrator } = require("../sessions/orchestrator.cjs");
|
|
57
|
+
const orch = new Orchestrator({ db: ctx.db(), lang: ctx.lang });
|
|
58
|
+
const session = orch.spawn({
|
|
59
|
+
agent: spec.agent,
|
|
60
|
+
runtime: spec.runtime,
|
|
61
|
+
permission: spec.permission,
|
|
62
|
+
cwd: spec.cwd,
|
|
63
|
+
title: prompt.slice(0, 60),
|
|
64
|
+
});
|
|
65
|
+
return { orch, session };
|
|
66
|
+
});
|
|
55
67
|
return {
|
|
56
68
|
async newSession(ctx, { cwd, runtimeKind, agentSlug }) {
|
|
57
69
|
const { projectCwd } = require("../project/paths.cjs");
|
|
@@ -81,9 +93,17 @@ function productionTurnRunner() {
|
|
|
81
93
|
return { agent, runtime, permission, cwd: workdir, project: resolved ? resolved.project : null };
|
|
82
94
|
},
|
|
83
95
|
async runTurn(ctx, spec, prompt, events) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
96
|
+
// An ACP session is conversational. Recreating Terminal's Session here on
|
|
97
|
+
// every prompt gave each editor turn a new chat/runtime fingerprint even
|
|
98
|
+
// though the client kept sending the same ACP sessionId. Keep one Session
|
|
99
|
+
// on the session spec so DB history and provider resume state survive.
|
|
100
|
+
if (!spec.session) {
|
|
101
|
+
const created = await createSession(ctx, spec, prompt);
|
|
102
|
+
spec.orch = created && created.orch || null;
|
|
103
|
+
spec.session = created && created.session || created;
|
|
104
|
+
}
|
|
105
|
+
const session = spec.session;
|
|
106
|
+
if (!session || typeof session.send !== "function") throw new Error("ACP session runner failed to create a Terminal session");
|
|
87
107
|
sessions.set(spec.acpSessionId, session);
|
|
88
108
|
const listener = (ev) => {
|
|
89
109
|
try {
|
|
@@ -104,7 +124,7 @@ function productionTurnRunner() {
|
|
|
104
124
|
};
|
|
105
125
|
} finally {
|
|
106
126
|
session.removeListener("event", listener);
|
|
107
|
-
sessions.delete(spec.acpSessionId);
|
|
127
|
+
if (sessions.get(spec.acpSessionId) === session) sessions.delete(spec.acpSessionId);
|
|
108
128
|
}
|
|
109
129
|
},
|
|
110
130
|
cancel(acpSessionId) {
|
|
@@ -207,9 +227,15 @@ class AcpAgentServer {
|
|
|
207
227
|
case "session/prompt": {
|
|
208
228
|
const spec = this.sessions.get(String(params.sessionId));
|
|
209
229
|
if (!spec) return this.error(id, -32602, "unknown sessionId");
|
|
230
|
+
// readline dispatches messages concurrently. Session.send() queues a
|
|
231
|
+
// second prompt and returns the first turn's promise, which would make
|
|
232
|
+
// both JSON-RPC requests stream each other's events and settle with the
|
|
233
|
+
// wrong result. ACP clients must wait for the current prompt response.
|
|
234
|
+
if (spec.promptInFlight) return this.error(id, -32001, "session prompt already in progress");
|
|
210
235
|
const prompt = textOfPrompt(params.prompt).trim();
|
|
211
236
|
if (!prompt) return this.reply(id, { stopReason: "end_turn" });
|
|
212
237
|
const sid = spec.acpSessionId;
|
|
238
|
+
spec.promptInFlight = true;
|
|
213
239
|
let streamed = false;
|
|
214
240
|
const events = {
|
|
215
241
|
onDelta: (text) => {
|
|
@@ -232,18 +258,22 @@ class AcpAgentServer {
|
|
|
232
258
|
},
|
|
233
259
|
onStatus: () => {},
|
|
234
260
|
};
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
261
|
+
try {
|
|
262
|
+
const res = await this.runner.runTurn(this.ctx, spec, prompt, events);
|
|
263
|
+
if (res && res.cancelled) return this.reply(id, { stopReason: "cancelled" });
|
|
264
|
+
if (res && res.error) {
|
|
265
|
+
const kind = String(res.errorKind || "");
|
|
266
|
+
if (kind === "refused") return this.reply(id, { stopReason: "refusal" });
|
|
267
|
+
if (kind === "auth") return this.error(id, -32000, `auth_required: ${res.error}`);
|
|
268
|
+
// Non-marker failures: surface the runtime's own words as the answer, then end the turn.
|
|
269
|
+
if (!streamed) events.onDelta(String(res.error));
|
|
270
|
+
return this.reply(id, { stopReason: "end_turn", _meta: { agentlas: { error: String(res.error), errorKind: kind || null } } });
|
|
271
|
+
}
|
|
272
|
+
if (!streamed && res && res.text) events.onDelta(res.text);
|
|
273
|
+
return this.reply(id, { stopReason: "end_turn" });
|
|
274
|
+
} finally {
|
|
275
|
+
spec.promptInFlight = false;
|
|
244
276
|
}
|
|
245
|
-
if (!streamed && res && res.text) events.onDelta(res.text);
|
|
246
|
-
return this.reply(id, { stopReason: "end_turn" });
|
|
247
277
|
}
|
|
248
278
|
case "session/cancel": {
|
|
249
279
|
const spec = this.sessions.get(String(params.sessionId));
|
|
@@ -110,6 +110,37 @@ function currentTargetHash(file) {
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
function writeTargetAtomic(file, content) {
|
|
114
|
+
const dir = path.dirname(file);
|
|
115
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
116
|
+
let mode = 0o600;
|
|
117
|
+
try {
|
|
118
|
+
const stat = fs.lstatSync(file);
|
|
119
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Evolution target must be a regular non-symbolic-link file");
|
|
120
|
+
mode = stat.mode & 0o777;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
123
|
+
}
|
|
124
|
+
const temp = path.join(dir, `.${path.basename(file)}.${process.pid}.${randomUUID()}.tmp`);
|
|
125
|
+
try {
|
|
126
|
+
fs.writeFileSync(temp, content, { encoding: "utf8", mode, flag: "wx" });
|
|
127
|
+
fs.renameSync(temp, file);
|
|
128
|
+
try { fs.chmodSync(file, mode); } catch { /* Windows/ACL-only host */ }
|
|
129
|
+
} catch (error) {
|
|
130
|
+
try { fs.rmSync(temp, { force: true }); } catch { /* preserve original */ }
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function restoreTargetAfterFailure(file, before, expectedCurrentHash) {
|
|
136
|
+
const current = currentTargetHash(file);
|
|
137
|
+
if (current.hash !== expectedCurrentHash) {
|
|
138
|
+
throw new Error("Evolution persistence failed and the target changed again before rollback; manual repair is required");
|
|
139
|
+
}
|
|
140
|
+
if (before.exists) writeTargetAtomic(file, before.content);
|
|
141
|
+
else fs.rmSync(file, { force: true });
|
|
142
|
+
}
|
|
143
|
+
|
|
113
144
|
function printCard(out, entry, index) {
|
|
114
145
|
const { row, source } = entry;
|
|
115
146
|
const card = source.humanCard && typeof source.humanCard === "object" ? source.humanCard : null;
|
|
@@ -205,29 +236,37 @@ function cmdApply(db, id, out, fail, agentFolder) {
|
|
|
205
236
|
const agent = agentById(db, row.agent_id);
|
|
206
237
|
if (!agent) return fail(`Agent not found for proposal: ${row.agent_id}`);
|
|
207
238
|
const file = targetFilePath(agentFolder, agent, row.target_path);
|
|
208
|
-
const
|
|
209
|
-
if (
|
|
239
|
+
const before = currentTargetHash(file);
|
|
240
|
+
if (before.hash !== row.before_hash) {
|
|
210
241
|
return fail("Agent prompt changed after this proposal was created; review it in the desktop app and re-propose.");
|
|
211
242
|
}
|
|
212
243
|
const now = new Date().toISOString();
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
244
|
+
let wroteTarget = false;
|
|
245
|
+
try {
|
|
246
|
+
runWriteTransaction(db, () => {
|
|
247
|
+
const locked = db.prepare("SELECT status FROM agent_evolution_proposals WHERE id=?").get(row.id);
|
|
248
|
+
if (!locked || locked.status !== "candidate") throw new Error("Evolution proposal changed before apply; reload and review it again");
|
|
249
|
+
if (currentTargetHash(file).hash !== row.before_hash) throw new Error("Agent prompt changed before apply; reload and review it again");
|
|
250
|
+
writeTargetAtomic(file, row.after_content);
|
|
251
|
+
wroteTarget = true;
|
|
252
|
+
if (currentTargetHash(file).hash !== row.after_hash) throw new Error("Applied content did not match the approved hash");
|
|
253
|
+
db.prepare("UPDATE installed_agents SET system_prompt = ? WHERE id = ?").run(row.after_content, row.agent_id);
|
|
254
|
+
insertReceipt(db, row, "apply", row.before_hash, row.after_hash, now);
|
|
255
|
+
const changed = db.prepare(
|
|
256
|
+
`UPDATE agent_evolution_proposals
|
|
257
|
+
SET status = 'applied', applied_at = COALESCE(applied_at, ?),
|
|
258
|
+
last_error = NULL, updated_at = ?
|
|
259
|
+
WHERE id = ? AND status = 'candidate'`,
|
|
260
|
+
).run(now, now, row.id).changes;
|
|
261
|
+
if (changed !== 1) throw new Error("Evolution proposal changed before apply; no receipt was committed");
|
|
262
|
+
});
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (wroteTarget) {
|
|
265
|
+
try { restoreTargetAfterFailure(file, before, sha256(row.after_content)); }
|
|
266
|
+
catch (rollbackError) { return fail(`${error.message}. ${rollbackError.message}`); }
|
|
267
|
+
}
|
|
268
|
+
return fail(`${error.message}; the original target was restored.`);
|
|
220
269
|
}
|
|
221
|
-
runWriteTransaction(db, () => {
|
|
222
|
-
db.prepare("UPDATE installed_agents SET system_prompt = ? WHERE id = ?").run(row.after_content, row.agent_id);
|
|
223
|
-
insertReceipt(db, row, "apply", row.before_hash, row.after_hash, now);
|
|
224
|
-
db.prepare(
|
|
225
|
-
`UPDATE agent_evolution_proposals
|
|
226
|
-
SET status = 'applied', applied_at = COALESCE(applied_at, ?),
|
|
227
|
-
last_error = NULL, updated_at = ?
|
|
228
|
-
WHERE id = ? AND status = 'candidate'`,
|
|
229
|
-
).run(now, now, row.id);
|
|
230
|
-
});
|
|
231
270
|
out(`applied ${id} → ${row.target_path} (agent ${row.agent_id}). Revert with: agentlas evolve revert ${id}`);
|
|
232
271
|
}
|
|
233
272
|
|
|
@@ -244,27 +283,37 @@ function cmdRevert(db, id, out, fail, agentFolder) {
|
|
|
244
283
|
const agent = agentById(db, row.agent_id);
|
|
245
284
|
if (!agent) return fail(`Agent not found for proposal: ${row.agent_id}`);
|
|
246
285
|
const file = targetFilePath(agentFolder, agent, row.target_path);
|
|
247
|
-
const
|
|
248
|
-
if (
|
|
286
|
+
const before = currentTargetHash(file);
|
|
287
|
+
if (before.hash !== row.after_hash) {
|
|
249
288
|
return fail("Agent prompt changed after this proposal was applied; revert blocked to avoid clobbering newer edits.");
|
|
250
289
|
}
|
|
251
290
|
const now = new Date().toISOString();
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
291
|
+
let wroteTarget = false;
|
|
292
|
+
try {
|
|
293
|
+
runWriteTransaction(db, () => {
|
|
294
|
+
const locked = db.prepare("SELECT status FROM agent_evolution_proposals WHERE id=?").get(row.id);
|
|
295
|
+
if (!locked || !["applied", "measured"].includes(locked.status)) throw new Error("Evolution proposal changed before revert; reload it first");
|
|
296
|
+
if (currentTargetHash(file).hash !== row.after_hash) throw new Error("Agent prompt changed before revert; reload it first");
|
|
297
|
+
writeTargetAtomic(file, row.before_content);
|
|
298
|
+
wroteTarget = true;
|
|
299
|
+
if (currentTargetHash(file).hash !== row.before_hash) throw new Error("Reverted content did not match the original hash");
|
|
300
|
+
db.prepare("UPDATE installed_agents SET system_prompt = ? WHERE id = ?").run(row.before_content, row.agent_id);
|
|
301
|
+
insertReceipt(db, row, "rollback", row.after_hash, row.before_hash, now);
|
|
302
|
+
const changed = db.prepare(
|
|
303
|
+
`UPDATE agent_evolution_proposals
|
|
304
|
+
SET status = 'rolled_back', rolled_back_at = COALESCE(rolled_back_at, ?),
|
|
305
|
+
last_error = NULL, updated_at = ?
|
|
306
|
+
WHERE id = ? AND status IN ('applied','measured')`,
|
|
307
|
+
).run(now, now, row.id).changes;
|
|
308
|
+
if (changed !== 1) throw new Error("Evolution proposal changed before revert; no receipt was committed");
|
|
309
|
+
});
|
|
310
|
+
} catch (error) {
|
|
311
|
+
if (wroteTarget) {
|
|
312
|
+
try { restoreTargetAfterFailure(file, before, sha256(row.before_content)); }
|
|
313
|
+
catch (rollbackError) { return fail(`${error.message}. ${rollbackError.message}`); }
|
|
314
|
+
}
|
|
315
|
+
return fail(`${error.message}; the applied target was restored.`);
|
|
257
316
|
}
|
|
258
|
-
runWriteTransaction(db, () => {
|
|
259
|
-
db.prepare("UPDATE installed_agents SET system_prompt = ? WHERE id = ?").run(row.before_content, row.agent_id);
|
|
260
|
-
insertReceipt(db, row, "rollback", row.after_hash, row.before_hash, now);
|
|
261
|
-
db.prepare(
|
|
262
|
-
`UPDATE agent_evolution_proposals
|
|
263
|
-
SET status = 'rolled_back', rolled_back_at = COALESCE(rolled_back_at, ?),
|
|
264
|
-
last_error = NULL, updated_at = ?
|
|
265
|
-
WHERE id = ? AND status IN ('applied','measured')`,
|
|
266
|
-
).run(now, now, row.id);
|
|
267
|
-
});
|
|
268
317
|
out(`reverted ${id} → restored ${row.target_path} (agent ${row.agent_id}).`);
|
|
269
318
|
}
|
|
270
319
|
|
|
@@ -175,13 +175,24 @@ function tableExists(db, name) {
|
|
|
175
175
|
|
|
176
176
|
function appendRunEvent(db, input) {
|
|
177
177
|
if (!db || !tableExists(db, "run_events")) return false;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
db.prepare(
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
178
|
+
// Allocate seq and insert in one SQLite statement. The old SELECT(MAX)+INSERT
|
|
179
|
+
// pair allowed two Terminal processes to choose the same (run_id, seq); one
|
|
180
|
+
// INSERT OR IGNORE then vanished while this function still reported success.
|
|
181
|
+
const inserted = db.prepare(
|
|
182
|
+
`INSERT OR IGNORE INTO run_events
|
|
183
|
+
(id,run_id,seq,ts,kind,chat_id,automation_id,node_id,agent_id,payload_json)
|
|
184
|
+
SELECT ?,?,COALESCE(MAX(seq) + 1, 0),?,?,NULL,NULL,NULL,?,?
|
|
185
|
+
FROM run_events WHERE run_id=?`,
|
|
186
|
+
).run(
|
|
187
|
+
input.id,
|
|
188
|
+
input.runId,
|
|
189
|
+
input.ts,
|
|
190
|
+
input.kind,
|
|
191
|
+
input.agentId || null,
|
|
192
|
+
JSON.stringify(input.payload),
|
|
193
|
+
input.runId,
|
|
194
|
+
);
|
|
195
|
+
return Number(inserted?.changes || 0) === 1;
|
|
185
196
|
}
|
|
186
197
|
|
|
187
198
|
function persistRunReceipt(db, receipt, agentId) {
|
|
@@ -200,7 +211,9 @@ function persistRunReceipt(db, receipt, agentId) {
|
|
|
200
211
|
function recordIntakeDecision(db, input) {
|
|
201
212
|
const sourceHash = digestHex(INTAKE_POLICY_VERSION, input.agentId, input.memoryId || "none", input.exactBase?.agentReleaseId || "none", input.environmentKey || "none");
|
|
202
213
|
appendRunEvent(db, {
|
|
203
|
-
|
|
214
|
+
// The same curated memory can be observed by many distinct runs. Preserve
|
|
215
|
+
// retry idempotency within one run without collapsing later run evidence.
|
|
216
|
+
id: opaqueId("event", "experience-intake", input.runId, sourceHash),
|
|
204
217
|
runId: input.runId,
|
|
205
218
|
ts: input.ts,
|
|
206
219
|
kind: input.kind || "experience-intake-decision",
|
package/engine/agentlas.cjs
CHANGED
|
@@ -213,8 +213,8 @@ function main() {
|
|
|
213
213
|
* 에서 ls -la 실행 실증). 가장 가까운 명령을 제안하고 정직하게 멈춘다.
|
|
214
214
|
* 진짜 한 단어 작업은 따옴표+run -p 로 그대로 실행된다.
|
|
215
215
|
*/
|
|
216
|
-
if (
|
|
217
|
-
const token =
|
|
216
|
+
if (commandArgv.length === 1 && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(commandArgv[0])) {
|
|
217
|
+
const token = commandArgv[0];
|
|
218
218
|
const names = Object.keys(commands.COMMANDS)
|
|
219
219
|
.concat(Object.keys(commands.COMMAND_ALIASES || {}))
|
|
220
220
|
.concat(commands.NOT_YET_PORTED || []);
|
|
@@ -228,7 +228,10 @@ function main() {
|
|
|
228
228
|
: `See: agentlas help · to run it as a task: agentlas run -p "${token}"`);
|
|
229
229
|
process.exit(1);
|
|
230
230
|
}
|
|
231
|
-
|
|
231
|
+
// 전역 출력 플래그는 이미 parseOutputFlags 가 소비했다. 원래 normalized argv 를
|
|
232
|
+
// 다시 넘기면 `agentlas --json "do work"`가 모델에게 "--json do work"라고
|
|
233
|
+
// 지시하는 꼴이 된다. 실제 명령/작업 토큰만 실행 경로로 보낸다.
|
|
234
|
+
code = commands.COMMANDS.run().run(ctx, commandArgv);
|
|
232
235
|
}
|
|
233
236
|
|
|
234
237
|
Promise.resolve(code).then(
|
|
@@ -185,9 +185,30 @@ async function runAutomationOnce(ctx, db, row, opts = {}) {
|
|
|
185
185
|
// 데스크탑 스케줄러는 60초마다 리스를 갱신한다. 여기는 한 번 잡고 끝이라
|
|
186
186
|
// TTL(15분)을 넘긴 실행은 프로세스가 살아 있어도 회수 대상이 됐다 — 같은
|
|
187
187
|
// 자동화가 두 실행기에서 겹쳐 도는 경로. 같은 주기로 심장박동을 보낸다.
|
|
188
|
+
let activeSession = null;
|
|
189
|
+
let leaseOwnershipLost = false;
|
|
190
|
+
let leaseRenewWarningEmitted = false;
|
|
188
191
|
const leaseHeartbeat = setInterval(() => {
|
|
189
|
-
try {
|
|
190
|
-
|
|
192
|
+
try {
|
|
193
|
+
const renewed = store.renewAutomationLease(db, row.id);
|
|
194
|
+
if (!renewed) {
|
|
195
|
+
leaseOwnershipLost = true;
|
|
196
|
+
if (activeSession && typeof activeSession.kill === "function") activeSession.kill();
|
|
197
|
+
} else {
|
|
198
|
+
leaseRenewWarningEmitted = false;
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
// One SQLITE_BUSY/I/O miss is not ownership loss. Keep the run alive and
|
|
202
|
+
// retry on the next heartbeat, while surfacing the first deferred renewal.
|
|
203
|
+
if (!leaseRenewWarningEmitted) {
|
|
204
|
+
leaseRenewWarningEmitted = true;
|
|
205
|
+
const code = error && typeof error === "object" && "code" in error
|
|
206
|
+
? String(error.code || "transient")
|
|
207
|
+
: "transient";
|
|
208
|
+
ctx.err(`automation lease heartbeat deferred (${code.slice(0, 80)})`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}, Math.max(10, Number(opts.leaseHeartbeatMs) || 60_000));
|
|
191
212
|
if (typeof leaseHeartbeat.unref === "function") leaseHeartbeat.unref();
|
|
192
213
|
|
|
193
214
|
try {
|
|
@@ -244,9 +265,18 @@ async function runAutomationOnce(ctx, db, row, opts = {}) {
|
|
|
244
265
|
timeoutConfig: opts.timeoutConfig,
|
|
245
266
|
...(markerChatId ? { chatId: markerChatId } : {}),
|
|
246
267
|
});
|
|
268
|
+
activeSession = session;
|
|
269
|
+
if (leaseOwnershipLost) {
|
|
270
|
+
session.kill();
|
|
271
|
+
return { ok: false, skipped: true, reason: "lease-lost", error: "Automation execution lease ownership lost" };
|
|
272
|
+
}
|
|
247
273
|
if (typeof opts.onSession === "function") opts.onSession(session);
|
|
248
274
|
|
|
249
275
|
const res = await session.send(row.prompt_template);
|
|
276
|
+
if (leaseOwnershipLost) {
|
|
277
|
+
ctx.err("Automation execution lease ownership lost");
|
|
278
|
+
return { ok: false, skipped: true, reason: "lease-lost", error: "Automation execution lease ownership lost" };
|
|
279
|
+
}
|
|
250
280
|
const finalText = (res && (res.finalText || res.text)) || "";
|
|
251
281
|
const failed = session.status === "failed";
|
|
252
282
|
const errMsg = failed ? String(session.lastError || "runtime turn failed").slice(0, 500) : null;
|
|
@@ -265,12 +295,15 @@ async function runAutomationOnce(ctx, db, row, opts = {}) {
|
|
|
265
295
|
} catch (e) {
|
|
266
296
|
const msg = String((e && e.message) || e).slice(0, 500);
|
|
267
297
|
ctx.err(msg);
|
|
298
|
+
if (leaseOwnershipLost) {
|
|
299
|
+
return { ok: false, skipped: true, reason: "lease-lost", error: "Automation execution lease ownership lost" };
|
|
300
|
+
}
|
|
268
301
|
store.recordRun(db, row.id, "error", msg, opts.scheduledFor);
|
|
269
302
|
store.advanceAfterRun(db, row, { ok: false, advanceSchedule: !!opts.advanceSchedule });
|
|
270
303
|
return { ok: false, error: msg };
|
|
271
304
|
} finally {
|
|
272
305
|
clearInterval(leaseHeartbeat);
|
|
273
|
-
store.releaseAutomation(db, row.id);
|
|
306
|
+
store.releaseAutomation(db, row.id, store.LEASE_OWNER);
|
|
274
307
|
}
|
|
275
308
|
}
|
|
276
309
|
|
|
@@ -116,20 +116,22 @@ function claimAutomation(db, id, now = new Date(), owner = LEASE_OWNER) {
|
|
|
116
116
|
*/
|
|
117
117
|
function renewAutomationLease(db, id, now = new Date(), owner = LEASE_OWNER) {
|
|
118
118
|
if (!leaseSupported(db)) return false;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
119
|
+
// false means definitive ownership loss. SQLite busy/I/O errors must throw
|
|
120
|
+
// so the caller can retry instead of killing a valid run as if a peer had
|
|
121
|
+
// taken the lease (Desktop renewAutomationRunLease contract).
|
|
122
|
+
const result = db
|
|
123
|
+
.prepare("UPDATE automations SET claimed_at = ? WHERE id = ? AND lease_owner = ? AND claimed_at IS NOT NULL")
|
|
124
|
+
.run(now.toISOString(), id, owner);
|
|
125
|
+
return (result.changes ?? result.rowsAffected ?? 0) > 0;
|
|
127
126
|
}
|
|
128
127
|
|
|
129
|
-
function releaseAutomation(db, id) {
|
|
128
|
+
function releaseAutomation(db, id, owner = LEASE_OWNER) {
|
|
130
129
|
try {
|
|
131
|
-
|
|
132
|
-
|
|
130
|
+
const result = db
|
|
131
|
+
.prepare("UPDATE automations SET claimed_at = NULL, lease_owner = NULL WHERE id = ? AND lease_owner = ?")
|
|
132
|
+
.run(id, owner);
|
|
133
|
+
return (result.changes ?? result.rowsAffected ?? 0) > 0;
|
|
134
|
+
} catch { return false; }
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
-- Agentlas 첫 실행 부트스트랩 스키마 (생성: 2026-08-
|
|
1
|
+
-- Agentlas 첫 실행 부트스트랩 스키마 (생성: 2026-08-29T07:55:23Z)
|
|
2
2
|
--
|
|
3
3
|
-- ★생성물이다. 손으로 고치지 말고 재생성하라:
|
|
4
4
|
-- node scripts/gen-bootstrap-schema.cjs
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
-- 정본은 Desktop 의 마이그레이션 사다리(agentlas_desktop/electron/store/db.ts, SCHEMA_VERSION).
|
|
7
7
|
-- 이 파일은 그 사다리를 **빈 DB** 에 끝까지 돌린 결과의 덤프이므로, 터미널이 만든 DB 는
|
|
8
8
|
-- 처음부터 사다리 머리에 있다 — 데스크탑이 나중에 승급할 것이 남지 않는다.
|
|
9
|
-
PRAGMA user_version=
|
|
9
|
+
PRAGMA user_version=105;
|
|
10
10
|
CREATE TABLE active_runtime (
|
|
11
11
|
id INTEGER PRIMARY KEY CHECK(id = 1),
|
|
12
12
|
kind TEXT NOT NULL
|
|
@@ -867,6 +867,20 @@ CREATE TABLE installed_agents (
|
|
|
867
867
|
installed_at TEXT NOT NULL,
|
|
868
868
|
tone TEXT NOT NULL
|
|
869
869
|
, env_requirements_json TEXT NOT NULL DEFAULT '[]', name_en TEXT NOT NULL DEFAULT '', tagline_en TEXT NOT NULL DEFAULT '', builtin INTEGER NOT NULL DEFAULT 0, role TEXT, visibility TEXT NOT NULL DEFAULT 'visible' CHECK(visibility IN ('visible','background','private')), entity_kind TEXT, local_display_name TEXT, bookmarked_at TEXT NULL, parent_team_id TEXT NULL);
|
|
870
|
+
CREATE TABLE invocation_steers (
|
|
871
|
+
id TEXT PRIMARY KEY,
|
|
872
|
+
chat_id TEXT NOT NULL,
|
|
873
|
+
original_run_id TEXT NOT NULL,
|
|
874
|
+
prompt_text TEXT NOT NULL,
|
|
875
|
+
prompt_hash TEXT NOT NULL,
|
|
876
|
+
request_json TEXT NOT NULL,
|
|
877
|
+
workspace_binding_json TEXT,
|
|
878
|
+
execution_context_json TEXT,
|
|
879
|
+
status TEXT NOT NULL CHECK(status IN ('queued','draining','started','cancelled','failed')),
|
|
880
|
+
drained_run_id TEXT,
|
|
881
|
+
queued_at TEXT NOT NULL,
|
|
882
|
+
updated_at TEXT NOT NULL
|
|
883
|
+
);
|
|
870
884
|
CREATE TABLE judgment_verdicts (
|
|
871
885
|
kind TEXT NOT NULL,
|
|
872
886
|
signature TEXT NOT NULL,
|
|
@@ -1071,6 +1085,7 @@ CREATE TABLE one_org_members (
|
|
|
1071
1085
|
pending_count INTEGER NOT NULL DEFAULT 0,
|
|
1072
1086
|
pending_kind TEXT NOT NULL DEFAULT 'approval' CHECK(pending_kind IN ('approval','review','input')),
|
|
1073
1087
|
unread_count INTEGER NOT NULL DEFAULT 0,
|
|
1088
|
+
unread_generation INTEGER NOT NULL DEFAULT 0,
|
|
1074
1089
|
credit_state TEXT NOT NULL DEFAULT 'unknown' CHECK(credit_state IN ('ok','insufficient','unknown')),
|
|
1075
1090
|
auto_select_tools INTEGER NOT NULL DEFAULT 1 CHECK(auto_select_tools IN (0,1)),
|
|
1076
1091
|
collaboration_style TEXT NOT NULL DEFAULT 'default' CHECK(collaboration_style IN ('default','concise','warm','direct')),
|
|
@@ -1135,6 +1150,13 @@ CREATE TABLE projects (
|
|
|
1135
1150
|
updated_at TEXT NOT NULL, folder_path TEXT, system_prompt TEXT, agent_pool_json TEXT NOT NULL DEFAULT '[]', source_type TEXT NOT NULL DEFAULT 'local', source_ref TEXT,
|
|
1136
1151
|
FOREIGN KEY(default_agent_id) REFERENCES installed_agents(id) ON DELETE SET NULL
|
|
1137
1152
|
);
|
|
1153
|
+
CREATE TABLE prompt_chat_start_intents (
|
|
1154
|
+
intent_id TEXT PRIMARY KEY,
|
|
1155
|
+
chat_id TEXT NOT NULL UNIQUE,
|
|
1156
|
+
prompt_digest TEXT NOT NULL,
|
|
1157
|
+
seed_only INTEGER NOT NULL CHECK(seed_only IN (0,1)),
|
|
1158
|
+
created_at TEXT NOT NULL
|
|
1159
|
+
);
|
|
1138
1160
|
CREATE TABLE run_events (
|
|
1139
1161
|
id TEXT PRIMARY KEY,
|
|
1140
1162
|
run_id TEXT NOT NULL,
|
|
@@ -1426,6 +1448,10 @@ CREATE INDEX idx_installed_agent_hub_binding_exact
|
|
|
1426
1448
|
ON installed_agent_hub_bindings(agent_definition_id, agent_release_id);
|
|
1427
1449
|
CREATE INDEX idx_installed_agents_parent_team ON installed_agents(parent_team_id) WHERE parent_team_id IS NOT NULL;
|
|
1428
1450
|
CREATE INDEX idx_installed_agents_visibility ON installed_agents(visibility, installed_at DESC);
|
|
1451
|
+
CREATE INDEX idx_invocation_steers_chat
|
|
1452
|
+
ON invocation_steers(chat_id, queued_at, id);
|
|
1453
|
+
CREATE INDEX idx_invocation_steers_queue
|
|
1454
|
+
ON invocation_steers(status, queued_at, id);
|
|
1429
1455
|
CREATE INDEX idx_judgment_verdicts_recency ON judgment_verdicts(last_hit_at);
|
|
1430
1456
|
CREATE INDEX idx_memory_agent ON memory_entries(agent_id, superseded_at);
|
|
1431
1457
|
CREATE INDEX idx_memory_chat ON memory_entries(chat_id);
|
|
@@ -1467,6 +1493,8 @@ CREATE INDEX idx_plugin_builder_sessions_chat_updated
|
|
|
1467
1493
|
ON plugin_builder_sessions(chat_id, updated_at DESC);
|
|
1468
1494
|
CREATE INDEX idx_plugin_builder_sessions_slug_phase
|
|
1469
1495
|
ON plugin_builder_sessions(slug, phase);
|
|
1496
|
+
CREATE INDEX idx_prompt_chat_start_chat
|
|
1497
|
+
ON prompt_chat_start_intents(chat_id);
|
|
1470
1498
|
CREATE INDEX idx_run_events_agent_kind_ts
|
|
1471
1499
|
ON run_events(agent_id, kind, ts DESC);
|
|
1472
1500
|
CREATE INDEX idx_run_events_agent_ts ON run_events(agent_id, ts DESC);
|
package/engine/cloud/auth.cjs
CHANGED
|
@@ -24,6 +24,12 @@ const LOGIN_CALLBACK_PATH = "/callback";
|
|
|
24
24
|
const LOGIN_TIMEOUT_MS = 180_000;
|
|
25
25
|
const MAX_LOGIN_SESSION_BYTES = 16 * 1024;
|
|
26
26
|
|
|
27
|
+
function validLoginSessionValue(value) {
|
|
28
|
+
return typeof value === "string" && value.length > 0 &&
|
|
29
|
+
Buffer.byteLength(value, "utf8") <= MAX_LOGIN_SESSION_BYTES &&
|
|
30
|
+
!/[\u0000-\u0020\u007f;,]/.test(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
27
33
|
function webBaseUrl() {
|
|
28
34
|
return (process.env.AGENTLAS_WEB_BASE_URL || "https://agentlas.cloud").replace(/\/$/, "");
|
|
29
35
|
}
|
|
@@ -97,13 +103,13 @@ function createLoginCallbackGuard(expectedState) {
|
|
|
97
103
|
message: "The callback did not include a session value.",
|
|
98
104
|
};
|
|
99
105
|
}
|
|
100
|
-
if (
|
|
106
|
+
if (!validLoginSessionValue(value)) {
|
|
101
107
|
return {
|
|
102
108
|
handled: true,
|
|
103
109
|
final: true,
|
|
104
110
|
ok: false,
|
|
105
111
|
statusCode: 400,
|
|
106
|
-
message: "The login session value is
|
|
112
|
+
message: "The login session value is invalid.",
|
|
107
113
|
};
|
|
108
114
|
}
|
|
109
115
|
return { handled: true, final: true, ok: true, statusCode: 200, value, message: "Agentlas login complete" };
|
|
@@ -120,6 +126,9 @@ function openInBrowser(url) {
|
|
|
120
126
|
: ["xdg-open", url];
|
|
121
127
|
try {
|
|
122
128
|
const child = spawn(argv[0], argv.slice(1), { stdio: "ignore", detached: true });
|
|
129
|
+
// spawn failures such as a missing xdg-open arrive asynchronously. Without
|
|
130
|
+
// a listener Node treats them as an uncaught process error.
|
|
131
|
+
child.once("error", () => {});
|
|
123
132
|
child.unref();
|
|
124
133
|
} catch { /* ignore */ }
|
|
125
134
|
}
|
|
@@ -220,18 +229,35 @@ function cliSessionPath() {
|
|
|
220
229
|
|
|
221
230
|
function readCliSessionValue() {
|
|
222
231
|
try {
|
|
223
|
-
const
|
|
224
|
-
|
|
232
|
+
const p = cliSessionPath();
|
|
233
|
+
const stat = fs.lstatSync(p);
|
|
234
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAX_LOGIN_SESSION_BYTES * 2) return null;
|
|
235
|
+
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
236
|
+
return j && validLoginSessionValue(j.value) ? j.value : null;
|
|
225
237
|
} catch {
|
|
226
238
|
return null;
|
|
227
239
|
}
|
|
228
240
|
}
|
|
229
241
|
|
|
230
242
|
function saveCliSession(value) {
|
|
243
|
+
if (!validLoginSessionValue(value)) throw new Error("Refusing to save an invalid Agentlas login session value.");
|
|
231
244
|
const p = cliSessionPath();
|
|
232
245
|
// 디렉터리 0700 + 파일 0600 — 기본 umask(0644)로 세션이 world-readable 이 되는 것을 막는다.
|
|
233
|
-
|
|
234
|
-
fs.
|
|
246
|
+
const dir = path.dirname(p);
|
|
247
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
248
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* win32 */ }
|
|
249
|
+
const temp = path.join(dir, `.cli-session.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
250
|
+
try {
|
|
251
|
+
fs.writeFileSync(temp, JSON.stringify({ version: 1, value, updatedAt: new Date().toISOString() }, null, 2) + "\n", {
|
|
252
|
+
encoding: "utf8",
|
|
253
|
+
mode: 0o600,
|
|
254
|
+
flag: "wx",
|
|
255
|
+
});
|
|
256
|
+
fs.renameSync(temp, p);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
try { fs.rmSync(temp, { force: true }); } catch { /* preserve original */ }
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
235
261
|
try { fs.chmodSync(p, 0o600); } catch { /* win32 */ }
|
|
236
262
|
return p;
|
|
237
263
|
}
|
|
@@ -246,7 +272,7 @@ function deleteCliSession() {
|
|
|
246
272
|
// 쿠키 해석 순서 계약: AGENTLAS_SESSION env → 세션 파일. (v1의 keytar 폴백은
|
|
247
273
|
// 데스크탑이 세션을 keytar에 두지 않아 항상 비어 있었다 — v2에서는 싣지 않는다.)
|
|
248
274
|
function cloudSessionCookie() {
|
|
249
|
-
if (process.env.AGENTLAS_SESSION) return `agentlas_session=${process.env.AGENTLAS_SESSION}`;
|
|
275
|
+
if (validLoginSessionValue(process.env.AGENTLAS_SESSION)) return `agentlas_session=${process.env.AGENTLAS_SESSION}`;
|
|
250
276
|
const fileValue = readCliSessionValue();
|
|
251
277
|
if (fileValue) return `agentlas_session=${fileValue}`;
|
|
252
278
|
return null;
|
|
@@ -275,5 +301,5 @@ module.exports = {
|
|
|
275
301
|
deleteCliSession,
|
|
276
302
|
cloudSessionCookie,
|
|
277
303
|
fetchSessionMeta,
|
|
278
|
-
_test: { createLoginState, createLoginCallbackGuard },
|
|
304
|
+
_test: { createLoginState, createLoginCallbackGuard, validLoginSessionValue },
|
|
279
305
|
};
|