hippo-memory 1.40.0 → 1.41.0

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/dist/src/index.js CHANGED
@@ -5,11 +5,11 @@ export { Layer, calculateStrength, resolveConfidence, confidenceFacets, createMe
5
5
  export { search, hybridSearch, physicsSearch, markRetrieved, estimateTokens, textOverlap, tokenize, explainMatch, detectTemporalDirection, temporalBoost, computeTemporalRange } from './search.js';
6
6
  export { multihopSearch } from './multihop.js';
7
7
  export { graphExpandRecall, MAX_HOPS, DEFAULT_MAX_NEIGHBORS } from './graph-recall.js';
8
- export { initStore, loadAllEntries, loadSearchEntries, loadRecallSearchEntries, writeEntry, readEntry, deleteEntry, loadIndex, rebuildIndex, saveActiveTaskSnapshot, loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, SNAPSHOT_AMBIENT_MAX_AGE_MS, closeTaskSnapshotsForSession, clearActiveTaskSnapshot, appendSessionEvent, listSessionEvents, listMemoryConflicts, replaceDetectedConflicts, resolveConflict, saveSessionHandoff, loadLatestHandoff, loadHandoffById, stampHandoffOutcome, writeSessionEndHandoff, loadSessionDecayContext, createCard, loadCard, listCards, loadCardDeps, loadCardRuns, loadCardComments, claimCard, blockCard, reviewCard, completeCard, addCardComment, loadLatestHandoffForCard, } from './store.js';
8
+ export { initStore, loadAllEntries, loadSearchEntries, loadRecallSearchEntries, writeEntry, readEntry, deleteEntry, loadIndex, rebuildIndex, saveActiveTaskSnapshot, loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, SNAPSHOT_AMBIENT_MAX_AGE_MS, closeTaskSnapshotsForSession, clearActiveTaskSnapshot, appendSessionEvent, listSessionEvents, listMemoryConflicts, replaceDetectedConflicts, resolveConflict, saveSessionHandoff, loadLatestHandoff, loadHandoffById, stampHandoffOutcome, writeSessionEndHandoff, loadSessionDecayContext, createCard, loadCard, listCards, loadCardDeps, loadCardRuns, loadCardComments, claimCard, heartbeatCard, blockCard, reviewCard, completeCard, reclaimExpiredCards, addCardComment, loadLatestHandoffForCard, } from './store.js';
9
9
  // Feature 5: Session handoff
10
10
  export { isHandoffOutcome } from './handoff.js';
11
11
  // W2a: work-queue cards
12
- export { isCardStatus, CARD_TRANSITIONS } from './card.js';
12
+ export { isCardStatus, CARD_TRANSITIONS, CARD_LEASE_MS } from './card.js';
13
13
  export { consolidate } from './consolidate.js';
14
14
  // Announced public in CHANGELOG 1.26.3 but never re-exported; the rest of dedupe.js stays internal.
15
15
  export { strengthBucket } from './dedupe.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAe,KAAK,EAAmD,iBAAiB,EAAE,iBAAiB,EAAE,gBAAgB,EAAyB,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC3O,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,aAAa,EAAE,aAAa,EAAE,cAAc,EAAE,WAAW,EAAE,QAAQ,EAAE,YAAY,EAAE,uBAAuB,EAAE,aAAa,EAAE,oBAAoB,EAAkC,MAAM,aAAa,CAAC;AACpO,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,iBAAiB,EAAE,QAAQ,EAAE,qBAAqB,EAAwB,MAAM,mBAAmB,CAAC;AAC7G,OAAO,EACL,SAAS,EACT,cAAc,EACd,iBAAiB,EACjB,uBAAuB,EACvB,UAAU,EACV,SAAS,EACT,WAAW,EACX,SAAS,EACT,YAAY,EACZ,sBAAsB,EACtB,sBAAsB,EACtB,2BAA2B,EAC3B,2BAA2B,EAC3B,4BAA4B,EAC5B,uBAAuB,EACvB,kBAAkB,EAClB,iBAAiB,EACjB,mBAAmB,EACnB,wBAAwB,EACxB,eAAe,EACf,kBAAkB,EAClB,iBAAiB,EACjB,eAAe,EACf,mBAAmB,EACnB,sBAAsB,EACtB,uBAAuB,EAEvB,UAAU,EACV,QAAQ,EACR,SAAS,EACT,YAAY,EACZ,YAAY,EACZ,gBAAgB,EAChB,SAAS,EACT,SAAS,EACT,UAAU,EACV,YAAY,EACZ,cAAc,EACd,wBAAwB,GACzB,MAAM,YAAY,CAAC;AAEpB,6BAA6B;AAC7B,OAAO,EAAmD,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACjG,wBAAwB;AACxB,OAAO,EAA2D,YAAY,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AACpH,OAAO,EAAE,WAAW,EAAuB,MAAM,kBAAkB,CAAC;AACpE,oGAAoG;AACpG,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAE7C,8BAA8B;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,kBAAkB,EAClB,kBAAkB,EAClB,WAAW,EACX,QAAQ,GACT,MAAM,iBAAiB,CAAC;AAEzB,oCAAoC;AACpC,OAAO,EACL,YAAY,EACZ,cAAc,EACd,gBAAgB,EAChB,iBAAiB,EACjB,UAAU,EACV,WAAW,GACZ,MAAM,gBAAgB,CAAC;AAExB,uCAAuC;AACvC,OAAO,EACL,aAAa,EACb,UAAU,EACV,eAAe,EACf,UAAU,EACV,gBAAgB,EAChB,iBAAiB,EAEjB,aAAa,EACb,WAAW,EACX,SAAS,EACT,SAAS,GACV,MAAM,aAAa,CAAC;AAErB,4BAA4B;AAC5B,OAAO,EACL,MAAM,EACN,MAAM,EACN,OAAO,EACP,OAAO,EAEP,cAAc,GACf,MAAM,qBAAqB,CAAC;AAE7B,8BAA8B;AAC9B,OAAO,EACL,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,iBAAiB,EACjB,cAAc,EACd,WAAW,EACX,aAAa,GAGd,MAAM,gBAAgB,CAAC;AAExB,qBAAqB;AACrB,OAAO,EACL,cAAc,EACd,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,EACjB,oBAAoB,GACrB,MAAM,iBAAiB,CAAC;AAEzB,8BAA8B;AAC9B,OAAO,EACL,eAAe,GAIhB,MAAM,eAAe,CAAC;AAEvB,qCAAqC;AACrC,OAAO,EACL,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,GAEpB,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAe,KAAK,EAAmD,iBAAiB,EAAE,iBAAiB,EAAE,gBAAgB,EAAyB,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC3O,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,aAAa,EAAE,aAAa,EAAE,cAAc,EAAE,WAAW,EAAE,QAAQ,EAAE,YAAY,EAAE,uBAAuB,EAAE,aAAa,EAAE,oBAAoB,EAAkC,MAAM,aAAa,CAAC;AACpO,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,iBAAiB,EAAE,QAAQ,EAAE,qBAAqB,EAAwB,MAAM,mBAAmB,CAAC;AAC7G,OAAO,EACL,SAAS,EACT,cAAc,EACd,iBAAiB,EACjB,uBAAuB,EACvB,UAAU,EACV,SAAS,EACT,WAAW,EACX,SAAS,EACT,YAAY,EACZ,sBAAsB,EACtB,sBAAsB,EACtB,2BAA2B,EAC3B,2BAA2B,EAC3B,4BAA4B,EAC5B,uBAAuB,EACvB,kBAAkB,EAClB,iBAAiB,EACjB,mBAAmB,EACnB,wBAAwB,EACxB,eAAe,EACf,kBAAkB,EAClB,iBAAiB,EACjB,eAAe,EACf,mBAAmB,EACnB,sBAAsB,EACtB,uBAAuB,EAEvB,UAAU,EACV,QAAQ,EACR,SAAS,EACT,YAAY,EACZ,YAAY,EACZ,gBAAgB,EAChB,SAAS,EACT,aAAa,EACb,SAAS,EACT,UAAU,EACV,YAAY,EACZ,mBAAmB,EACnB,cAAc,EACd,wBAAwB,GACzB,MAAM,YAAY,CAAC;AAEpB,6BAA6B;AAC7B,OAAO,EAAmD,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACjG,wBAAwB;AACxB,OAAO,EAA2D,YAAY,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AACnI,OAAO,EAAE,WAAW,EAAuB,MAAM,kBAAkB,CAAC;AACpE,oGAAoG;AACpG,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAE7C,8BAA8B;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,kBAAkB,EAClB,kBAAkB,EAClB,WAAW,EACX,QAAQ,GACT,MAAM,iBAAiB,CAAC;AAEzB,oCAAoC;AACpC,OAAO,EACL,YAAY,EACZ,cAAc,EACd,gBAAgB,EAChB,iBAAiB,EACjB,UAAU,EACV,WAAW,GACZ,MAAM,gBAAgB,CAAC;AAExB,uCAAuC;AACvC,OAAO,EACL,aAAa,EACb,UAAU,EACV,eAAe,EACf,UAAU,EACV,gBAAgB,EAChB,iBAAiB,EAEjB,aAAa,EACb,WAAW,EACX,SAAS,EACT,SAAS,GACV,MAAM,aAAa,CAAC;AAErB,4BAA4B;AAC5B,OAAO,EACL,MAAM,EACN,MAAM,EACN,OAAO,EACP,OAAO,EAEP,cAAc,GACf,MAAM,qBAAqB,CAAC;AAE7B,8BAA8B;AAC9B,OAAO,EACL,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,iBAAiB,EACjB,cAAc,EACd,WAAW,EACX,aAAa,GAGd,MAAM,gBAAgB,CAAC;AAExB,qBAAqB;AACrB,OAAO,EACL,cAAc,EACd,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,EACjB,oBAAoB,GACrB,MAAM,iBAAiB,CAAC;AAEzB,8BAA8B;AAC9B,OAAO,EACL,eAAe,GAIhB,MAAM,eAAe,CAAC;AAEvB,qCAAqC;AACrC,OAAO,EACL,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,GAEpB,MAAM,cAAc,CAAC"}
package/dist/src/store.js CHANGED
@@ -10,7 +10,7 @@ import { Layer, generateId } from './memory.js';
10
10
  import { dumpFrontmatter, parseFrontmatter } from './yaml.js';
11
11
  import { openHippoDb, closeHippoDb, getMeta, setMeta, isFtsAvailable, pruneConsolidationRuns, getHippoDbPath, } from './db.js';
12
12
  import { rowToSessionHandoff, isHandoffOutcome } from './handoff.js';
13
- import { CARD_TRANSITIONS } from './card.js';
13
+ import { CARD_TRANSITIONS, CARD_LEASE_MS } from './card.js';
14
14
  import { tokenize } from './search.js';
15
15
  import { appendAuditEvent } from './audit.js';
16
16
  import { resolveTenantId } from './tenant.js';
@@ -2868,6 +2868,29 @@ function insertCardComment(db, tenantId, cardId, author, body) {
2868
2868
  const id = Number(result.lastInsertRowid ?? 0);
2869
2869
  return { id, cardId, author, body, createdAt: now };
2870
2870
  }
2871
+ function leaseUntilFrom(now) {
2872
+ return new Date(Date.parse(now) + CARD_LEASE_MS).toISOString();
2873
+ }
2874
+ function assertRunId(runId) {
2875
+ if (!Number.isSafeInteger(runId) || runId <= 0) {
2876
+ throw new Error(`Invalid run id: ${runId} (expected a positive integer)`);
2877
+ }
2878
+ }
2879
+ // A second run that has not ended means a corrupt store; throw rather than guess which run the caller means.
2880
+ function isLiveRun(db, tenantId, cardId, runId) {
2881
+ // SAFETY: rows' shape matches the single `id` column named in the SELECT below.
2882
+ const rows = db.prepare(`SELECT id FROM card_runs WHERE tenant_id = ? AND card = ? AND ended IS NULL`).all(tenantId, cardId);
2883
+ if (rows.length > 1) {
2884
+ throw new Error(`card ${cardId} has ${rows.length} runs that have not ended`);
2885
+ }
2886
+ return rows[0]?.id === runId;
2887
+ }
2888
+ function closeLiveRun(db, tenantId, cardId, outcome, now) {
2889
+ db.prepare(`
2890
+ UPDATE card_runs SET ended = ?, outcome = ?, updated_at = ?
2891
+ WHERE card = ? AND tenant_id = ? AND ended IS NULL
2892
+ `).run(now, outcome, now, cardId, tenantId);
2893
+ }
2871
2894
  // The single status-mutating seam (rule 15): CARD_TRANSITIONS is the one
2872
2895
  // runtime authority, so a hand-copied wrong `from` list fails fast here.
2873
2896
  export function transitionCard(db, tenantId, cardId, from, to, extra) {
@@ -2877,12 +2900,14 @@ export function transitionCard(db, tenantId, cardId, from, to, extra) {
2877
2900
  }
2878
2901
  }
2879
2902
  const now = new Date().toISOString();
2903
+ // Lease columns follow status: set on the move to running, cleared on every other move (rule 15).
2904
+ const lease = to === 'running' ? [leaseUntilFrom(now), now] : [null, null];
2880
2905
  const fromPlaceholders = from.map(() => '?').join(', ');
2881
2906
  const sql = `
2882
- UPDATE cards SET status = ?, updated_at = ?${extra?.setSql ? `, ${extra.setSql}` : ''}
2907
+ UPDATE cards SET status = ?, updated_at = ?, lease_until = ?, heartbeat_at = ?${extra?.setSql ? `, ${extra.setSql}` : ''}
2883
2908
  WHERE id = ? AND tenant_id = ? AND status IN (${fromPlaceholders})${extra?.whereSql ? ` AND ${extra.whereSql}` : ''}
2884
2909
  `;
2885
- const params = [to, now, ...(extra?.params ?? []), cardId, tenantId, ...from];
2910
+ const params = [to, now, ...lease, ...(extra?.params ?? []), cardId, tenantId, ...from];
2886
2911
  const result = db.prepare(sql).run(...params);
2887
2912
  return Number(result.changes ?? 0);
2888
2913
  }
@@ -3046,7 +3071,7 @@ export function loadLatestHandoffForCard(hippoRoot, tenantId, cardId) {
3046
3071
  closeHippoDb(db);
3047
3072
  }
3048
3073
  }
3049
- /** Atomic claim: WHERE status IN (ready, blocked) AND assignee_runtime IS NULL decides the race. Throws on an unknown card id; returns null for a card not ready/blocked or already claimed. */
3074
+ /** Atomic claim: WHERE status IN (ready, blocked) AND assignee_runtime IS NULL decides the race. Throws on an unknown card id; returns null for a card not ready/blocked or already claimed. Sets a CARD_LEASE_MS lease and returns the new run's id as runId. */
3050
3075
  export function claimCard(hippoRoot, tenantId, id, runtime, sessionId) {
3051
3076
  assertTenantId('claimCard', tenantId);
3052
3077
  if (runtime.trim() === '') {
@@ -3056,6 +3081,7 @@ export function claimCard(hippoRoot, tenantId, id, runtime, sessionId) {
3056
3081
  const db = openHippoDb(hippoRoot);
3057
3082
  try {
3058
3083
  db.exec('BEGIN IMMEDIATE');
3084
+ let runId = 0;
3059
3085
  try {
3060
3086
  const changes = transitionCard(db, tenantId, id, ['ready', 'blocked'], 'running', {
3061
3087
  setSql: 'assignee_runtime = ?',
@@ -3070,10 +3096,46 @@ export function claimCard(hippoRoot, tenantId, id, runtime, sessionId) {
3070
3096
  return null;
3071
3097
  }
3072
3098
  const now = new Date().toISOString();
3073
- db.prepare(`
3099
+ const insert = db.prepare(`
3074
3100
  INSERT INTO card_runs (card, runtime, session_id, started, created_at, updated_at, tenant_id)
3075
3101
  VALUES (?, ?, ?, ?, ?, ?, ?)
3076
3102
  `).run(id, runtime, sessionId ?? null, now, now, now, tenantId);
3103
+ runId = Number(insert.lastInsertRowid ?? 0);
3104
+ db.exec('COMMIT');
3105
+ }
3106
+ catch (error) {
3107
+ try {
3108
+ db.exec('ROLLBACK');
3109
+ }
3110
+ catch { /* commit may have already rolled back */ }
3111
+ throw error;
3112
+ }
3113
+ return { ...loadCardRow(db, tenantId, id), runId };
3114
+ }
3115
+ finally {
3116
+ closeHippoDb(db);
3117
+ }
3118
+ }
3119
+ /** Moves a running card's lease to CARD_LEASE_MS from now and records the heartbeat; updated_at is left alone. Throws on an unknown card id or a run id that is not a positive integer; returns null unless the card is running and runId is its live run. */
3120
+ export function heartbeatCard(hippoRoot, tenantId, id, runId) {
3121
+ assertTenantId('heartbeatCard', tenantId);
3122
+ assertRunId(runId);
3123
+ initStore(hippoRoot);
3124
+ const db = openHippoDb(hippoRoot);
3125
+ try {
3126
+ db.exec('BEGIN IMMEDIATE');
3127
+ try {
3128
+ const card = loadCardRow(db, tenantId, id);
3129
+ if (!card) {
3130
+ throw new Error(`unknown card id: ${id}`);
3131
+ }
3132
+ if (card.status !== 'running' || !isLiveRun(db, tenantId, id, runId)) {
3133
+ db.exec('ROLLBACK');
3134
+ return null;
3135
+ }
3136
+ const now = new Date().toISOString();
3137
+ db.prepare(`UPDATE cards SET lease_until = ?, heartbeat_at = ? WHERE id = ? AND tenant_id = ?`)
3138
+ .run(leaseUntilFrom(now), now, id, tenantId);
3077
3139
  db.exec('COMMIT');
3078
3140
  }
3079
3141
  catch (error) {
@@ -3089,18 +3151,21 @@ export function claimCard(hippoRoot, tenantId, id, runtime, sessionId) {
3089
3151
  closeHippoDb(db);
3090
3152
  }
3091
3153
  }
3092
- /** Requires the card be running; closes the live run as blocked and files reason as a comment. Throws on an unknown card id; returns null for a card not running. */
3093
- export function blockCard(hippoRoot, tenantId, id, reason) {
3154
+ /** Requires the card be running; closes the live run as blocked and files reason as a comment. Throws on an unknown card id; returns null for a card not running. When runId is given, returns null unless it is the card's live run. */
3155
+ export function blockCard(hippoRoot, tenantId, id, reason, runId) {
3094
3156
  assertTenantId('blockCard', tenantId);
3095
3157
  if (reason.trim() === '') {
3096
3158
  throw new Error('reason must not be empty');
3097
3159
  }
3160
+ if (runId !== undefined)
3161
+ assertRunId(runId);
3098
3162
  initStore(hippoRoot);
3099
3163
  const db = openHippoDb(hippoRoot);
3100
3164
  try {
3101
3165
  db.exec('BEGIN IMMEDIATE');
3102
3166
  try {
3103
- const changes = transitionCard(db, tenantId, id, ['running'], 'blocked', { setSql: 'assignee_runtime = NULL' });
3167
+ const allowed = runId === undefined || isLiveRun(db, tenantId, id, runId);
3168
+ const changes = allowed ? transitionCard(db, tenantId, id, ['running'], 'blocked', { setSql: 'assignee_runtime = NULL' }) : 0;
3104
3169
  if (changes === 0) {
3105
3170
  if (!loadCardRow(db, tenantId, id)) {
3106
3171
  throw new Error(`unknown card id: ${id}`);
@@ -3110,10 +3175,7 @@ export function blockCard(hippoRoot, tenantId, id, reason) {
3110
3175
  }
3111
3176
  const now = new Date().toISOString();
3112
3177
  // Close the interrupted run here so completeCard's ended IS NULL scope only ever matches the live run.
3113
- db.prepare(`
3114
- UPDATE card_runs SET ended = ?, outcome = 'blocked', updated_at = ?
3115
- WHERE card = ? AND tenant_id = ? AND ended IS NULL
3116
- `).run(now, now, id, tenantId);
3178
+ closeLiveRun(db, tenantId, id, 'blocked', now);
3117
3179
  insertCardComment(db, tenantId, id, 'system', reason);
3118
3180
  db.exec('COMMIT');
3119
3181
  }
@@ -3130,15 +3192,18 @@ export function blockCard(hippoRoot, tenantId, id, reason) {
3130
3192
  closeHippoDb(db);
3131
3193
  }
3132
3194
  }
3133
- /** Requires the card be running; moves it to review with no other side effects. Throws on an unknown card id; returns null for a card not running. */
3134
- export function reviewCard(hippoRoot, tenantId, id) {
3195
+ /** Requires the card be running; moves it to review, clearing its lease and heartbeat and keeping its live run. When runId is given, returns null unless it is the card's live run. Throws on an unknown card id; returns null for a card not running. */
3196
+ export function reviewCard(hippoRoot, tenantId, id, runId) {
3135
3197
  assertTenantId('reviewCard', tenantId);
3198
+ if (runId !== undefined)
3199
+ assertRunId(runId);
3136
3200
  initStore(hippoRoot);
3137
3201
  const db = openHippoDb(hippoRoot);
3138
3202
  try {
3139
3203
  db.exec('BEGIN IMMEDIATE');
3140
3204
  try {
3141
- const changes = transitionCard(db, tenantId, id, ['running'], 'review');
3205
+ const allowed = runId === undefined || isLiveRun(db, tenantId, id, runId);
3206
+ const changes = allowed ? transitionCard(db, tenantId, id, ['running'], 'review') : 0;
3142
3207
  if (changes === 0) {
3143
3208
  if (!loadCardRow(db, tenantId, id)) {
3144
3209
  throw new Error(`unknown card id: ${id}`);
@@ -3161,12 +3226,14 @@ export function reviewCard(hippoRoot, tenantId, id) {
3161
3226
  closeHippoDb(db);
3162
3227
  }
3163
3228
  }
3164
- /** Requires the card be in review; closes the live run with outcome. Outcome 'success' moves the card to done and, in the same transaction, promotes any child whose parents are now all done; 'failure' or 'partial' moves it to shelved and promotes nothing. Throws on an unknown card id; returns null for a card not in review. */
3165
- export function completeCard(hippoRoot, tenantId, id, outcome) {
3229
+ /** Requires the card be in review; closes the live run with outcome. Outcome 'success' moves the card to done and, in the same transaction, promotes any child whose parents are now all done; 'failure' or 'partial' moves it to shelved and promotes nothing. Throws on an unknown card id; returns null for a card not in review. When runId is given, returns null unless it is the card's live run. */
3230
+ export function completeCard(hippoRoot, tenantId, id, outcome, runId) {
3166
3231
  assertTenantId('completeCard', tenantId);
3167
3232
  if (!isHandoffOutcome(outcome)) {
3168
3233
  throw new Error(`invalid card outcome: ${String(outcome)}`);
3169
3234
  }
3235
+ if (runId !== undefined)
3236
+ assertRunId(runId);
3170
3237
  initStore(hippoRoot);
3171
3238
  const db = openHippoDb(hippoRoot);
3172
3239
  try {
@@ -3174,7 +3241,8 @@ export function completeCard(hippoRoot, tenantId, id, outcome) {
3174
3241
  let promotedChildren = [];
3175
3242
  try {
3176
3243
  const target = outcome === 'success' ? 'done' : 'shelved';
3177
- const changes = transitionCard(db, tenantId, id, ['review'], target);
3244
+ const allowed = runId === undefined || isLiveRun(db, tenantId, id, runId);
3245
+ const changes = allowed ? transitionCard(db, tenantId, id, ['review'], target) : 0;
3178
3246
  if (changes === 0) {
3179
3247
  if (!loadCardRow(db, tenantId, id)) {
3180
3248
  throw new Error(`unknown card id: ${id}`);
@@ -3183,10 +3251,7 @@ export function completeCard(hippoRoot, tenantId, id, outcome) {
3183
3251
  return null;
3184
3252
  }
3185
3253
  const now = new Date().toISOString();
3186
- db.prepare(`
3187
- UPDATE card_runs SET ended = ?, outcome = ?, updated_at = ?
3188
- WHERE card = ? AND tenant_id = ? AND ended IS NULL
3189
- `).run(now, outcome, now, id, tenantId);
3254
+ closeLiveRun(db, tenantId, id, outcome, now);
3190
3255
  // Not best-effort (rule 12): promotion runs in this same transaction, so a
3191
3256
  // card can never be `done` with an un-evaluated child.
3192
3257
  if (target === 'done') {
@@ -3223,6 +3288,41 @@ export function completeCard(hippoRoot, tenantId, id, outcome) {
3223
3288
  closeHippoDb(db);
3224
3289
  }
3225
3290
  }
3291
+ /** Returns to ready every running card of the tenant whose lease has expired or is missing: clears its assignee, closes its live run as 'reclaimed' and leaves its handoffs alone, all in one write transaction. Returns the reclaimed card ids in id order. */
3292
+ export function reclaimExpiredCards(hippoRoot, tenantId) {
3293
+ assertTenantId('reclaimExpiredCards', tenantId);
3294
+ initStore(hippoRoot);
3295
+ const db = openHippoDb(hippoRoot);
3296
+ try {
3297
+ db.exec('BEGIN IMMEDIATE');
3298
+ try {
3299
+ // Read lease times under the write lock, so a heartbeat that committed while we waited wins.
3300
+ const now = new Date().toISOString();
3301
+ // SAFETY: rows' shape matches the single `id` column named in the SELECT below.
3302
+ const ids = db.prepare(`
3303
+ SELECT id FROM cards
3304
+ WHERE tenant_id = ? AND status = 'running' AND (lease_until IS NULL OR lease_until < ?)
3305
+ ORDER BY id
3306
+ `).all(tenantId, now).map((r) => r.id);
3307
+ for (const id of ids) {
3308
+ transitionCard(db, tenantId, id, ['running'], 'ready', { setSql: 'assignee_runtime = NULL' });
3309
+ closeLiveRun(db, tenantId, id, 'reclaimed', now);
3310
+ }
3311
+ db.exec('COMMIT');
3312
+ return ids;
3313
+ }
3314
+ catch (error) {
3315
+ try {
3316
+ db.exec('ROLLBACK');
3317
+ }
3318
+ catch { /* commit may have already rolled back */ }
3319
+ throw error;
3320
+ }
3321
+ }
3322
+ finally {
3323
+ closeHippoDb(db);
3324
+ }
3325
+ }
3226
3326
  /** Appends a comment to cardId in any card status; throws if cardId is not a card of this tenant. */
3227
3327
  export function addCardComment(hippoRoot, tenantId, cardId, author, body) {
3228
3328
  assertTenantId('addCardComment', tenantId);