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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-daemon",
3
- "version": "0.4.69",
3
+ "version": "0.4.71",
4
4
  "description": "Issue-driven AI development daemon. Spawns opencode subprocesses to resolve Gitea issues.",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
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
- private async webGateAllows(issue: Issue): Promise<{ allowed: boolean; reason: string }> {
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}`);