ework-daemon 0.4.69 → 0.4.71
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/package.json +1 -1
- package/src/db.ts +8 -0
- package/src/op.ts +33 -0
- package/src/opencode.ts +19 -5
package/package.json
CHANGED
package/src/db.ts
CHANGED
|
@@ -524,6 +524,14 @@ async function runMigrations(db: AsyncDatabase): Promise<void> {
|
|
|
524
524
|
: "owner_daemon_id BIGINT NULL"
|
|
525
525
|
);
|
|
526
526
|
|
|
527
|
+
// issues.reset_at — last consumed web-side session-reset marker (ms epoch).
|
|
528
|
+
// Lets the issue-page 🔄 button force a fresh AI session on next trigger.
|
|
529
|
+
await ensureColumn(
|
|
530
|
+
tIssues,
|
|
531
|
+
"reset_at",
|
|
532
|
+
sqlite ? "reset_at INTEGER NOT NULL DEFAULT 0" : "reset_at BIGINT NOT NULL DEFAULT 0"
|
|
533
|
+
);
|
|
534
|
+
|
|
527
535
|
// op_sessions runtime-state columns (previously in-memory Maps; now persisted
|
|
528
536
|
// so a restarted daemon can recover the nudge/generation state).
|
|
529
537
|
await ensureColumn(tSessions, "last_output_at", "last_output_at VARCHAR(40)");
|
package/src/op.ts
CHANGED
|
@@ -379,6 +379,22 @@ export class Store {
|
|
|
379
379
|
const db = getDB();
|
|
380
380
|
const cutoff = new Date(Date.now() - leaseTtlMs).toISOString();
|
|
381
381
|
|
|
382
|
+
// Identity reuse first: a fast restart (previous heartbeat still fresh)
|
|
383
|
+
// must keep the SAME row/id — daemon_id is embedded in web session links,
|
|
384
|
+
// so a fresh insert per boot orphans every historical link.
|
|
385
|
+
const mine = await db.get<{ id: number }>(
|
|
386
|
+
"SELECT id FROM {{daemons}} WHERE display_name = ? AND internal_endpoint = ? LIMIT 1",
|
|
387
|
+
[displayName, endpoint]
|
|
388
|
+
);
|
|
389
|
+
if (mine) {
|
|
390
|
+
const now = new Date().toISOString();
|
|
391
|
+
const res = await db.run(
|
|
392
|
+
"UPDATE {{daemons}} SET last_heartbeat = ?, status = 'active', capacity = ? WHERE id = ?",
|
|
393
|
+
[now, capacity, mine.id]
|
|
394
|
+
);
|
|
395
|
+
if (res.changes === 1) return mine.id;
|
|
396
|
+
}
|
|
397
|
+
|
|
382
398
|
const orphan = await db.get<{ id: number }>(
|
|
383
399
|
"SELECT id FROM {{daemons}} WHERE last_heartbeat < ? ORDER BY last_heartbeat LIMIT 1",
|
|
384
400
|
[cutoff]
|
|
@@ -447,6 +463,23 @@ export class Store {
|
|
|
447
463
|
);
|
|
448
464
|
}
|
|
449
465
|
|
|
466
|
+
// reset_at stores the last consumed web reset marker; newer markers clear session pointers.
|
|
467
|
+
async getIssueResetAt(issueUid: string): Promise<number> {
|
|
468
|
+
const row = await getDB().get<{ reset_at: number | null }>(
|
|
469
|
+
"SELECT reset_at FROM {{issues}} WHERE uid = ?",
|
|
470
|
+
[issueUid]
|
|
471
|
+
);
|
|
472
|
+
return row?.reset_at ?? 0;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async setIssueResetAt(issueUid: string, ms: number): Promise<void> {
|
|
476
|
+
await getDB().run("UPDATE {{issues}} SET reset_at = ? WHERE uid = ?", [ms, issueUid]);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async clearSessionPointers(issueUid: string): Promise<void> {
|
|
480
|
+
await getDB().run("UPDATE {{op_sessions}} SET opencode_session_id = NULL WHERE issue_id = ?", [issueUid]);
|
|
481
|
+
}
|
|
482
|
+
|
|
450
483
|
async markDaemonStatus(daemonId: number, status: "active" | "drained" | "dead"): Promise<void> {
|
|
451
484
|
await getDB().run(
|
|
452
485
|
"UPDATE {{daemons}} SET status = ? WHERE id = ?",
|
package/src/opencode.ts
CHANGED
|
@@ -300,7 +300,7 @@ export interface EngineOptions {
|
|
|
300
300
|
daemonId: number;
|
|
301
301
|
takeover?: TakeoverStrategy;
|
|
302
302
|
backend?: RuntimeBackend;
|
|
303
|
-
gateChecker?: (issue: Issue) => Promise<{ allowed: boolean; reason: string }>;
|
|
303
|
+
gateChecker?: (issue: Issue) => Promise<{ allowed: boolean; reason: string; resetMs?: number }>;
|
|
304
304
|
replyBurst?: { max: number; windowMs: number };
|
|
305
305
|
}
|
|
306
306
|
|
|
@@ -372,7 +372,7 @@ export class Engine {
|
|
|
372
372
|
private readonly daemonId: number;
|
|
373
373
|
private readonly takeover: TakeoverStrategy;
|
|
374
374
|
private readonly backend: RuntimeBackend;
|
|
375
|
-
private gateChecker: (issue: Issue) => Promise<{ allowed: boolean; reason: string }>;
|
|
375
|
+
private gateChecker: (issue: Issue) => Promise<{ allowed: boolean; reason: string; resetMs?: number }>;
|
|
376
376
|
private heartbeatTimer?: ReturnType<typeof setInterval>;
|
|
377
377
|
private maxConcurrent: number;
|
|
378
378
|
private maxConcurrentExplicit: boolean;
|
|
@@ -525,7 +525,18 @@ export class Engine {
|
|
|
525
525
|
}
|
|
526
526
|
}
|
|
527
527
|
|
|
528
|
-
|
|
528
|
+
// Consume-once: only a marker NEWER than issues.reset_at triggers the clear,
|
|
529
|
+
// so repeated triggers reuse the same fresh session until the next button press.
|
|
530
|
+
private async applySessionReset(session: OpSession, issue: Issue, resetMs: number): Promise<void> {
|
|
531
|
+
const last = await this.store.getIssueResetAt(issue.id);
|
|
532
|
+
if (resetMs <= last) return;
|
|
533
|
+
await this.store.clearSessionPointers(issue.id);
|
|
534
|
+
await this.store.setIssueResetAt(issue.id, resetMs);
|
|
535
|
+
session.opencodeSessionId = undefined;
|
|
536
|
+
log.info(`engine: session reset via web for ${issue.trackerScopeKey}#${issue.trackerIssueId} — pointers cleared, starting fresh session`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private async webGateAllows(issue: Issue): Promise<{ allowed: boolean; reason: string; resetMs?: number }> {
|
|
529
540
|
const parts = issue.trackerScopeKey.split("/");
|
|
530
541
|
const owner = parts[0] ?? "";
|
|
531
542
|
const repo = parts.slice(1).join("/");
|
|
@@ -534,10 +545,10 @@ export class Engine {
|
|
|
534
545
|
try {
|
|
535
546
|
const resp = await fetch(url, { signal: AbortSignal.timeout(5000), headers: { Authorization: `token ${this.cfg.gitea.token}` } });
|
|
536
547
|
if (!resp.ok) return { allowed: false, reason: `web returned ${resp.status}` };
|
|
537
|
-
const data = await resp.json() as { dispatchOff?: boolean; aiStatus?: string };
|
|
548
|
+
const data = await resp.json() as { dispatchOff?: boolean; aiStatus?: string; sessionResetMs?: number | null };
|
|
538
549
|
if (data.dispatchOff) return { allowed: false, reason: "dispatch off" };
|
|
539
550
|
if (data.aiStatus === "halted" || data.aiStatus === "dispatch_off") return { allowed: false, reason: `ai_status=${data.aiStatus}` };
|
|
540
|
-
return { allowed: true, reason: "ok" };
|
|
551
|
+
return { allowed: true, reason: "ok", resetMs: Number(data.sessionResetMs) || 0 };
|
|
541
552
|
} catch (err) {
|
|
542
553
|
log.warn(`engine: web gate query failed for ${issue.trackerScopeKey}#${issue.trackerIssueId}: ${(err as Error).message} — fail-closed (skipping)`);
|
|
543
554
|
return { allowed: false, reason: `web unreachable: ${(err as Error).message}` };
|
|
@@ -1345,6 +1356,9 @@ export class Engine {
|
|
|
1345
1356
|
|
|
1346
1357
|
private async execProcess(k: string, session: OpSession, issue: Issue, msg: Message) {
|
|
1347
1358
|
const gate = await this.gateChecker(issue);
|
|
1359
|
+
if (gate.allowed && gate.resetMs && gate.resetMs > 0) {
|
|
1360
|
+
await this.applySessionReset(session, issue, gate.resetMs);
|
|
1361
|
+
}
|
|
1348
1362
|
if (!gate.allowed) {
|
|
1349
1363
|
log.info(`engine: execProcess blocked by web gate (${gate.reason}) for ${k}`);
|
|
1350
1364
|
await this.store.updateMessageStatus(msg.id, "failed", `gate: ${gate.reason}`);
|