@try-works/dsh-memory 0.1.11 → 0.1.13

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/tasks.js +53 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@try-works/dsh-memory",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Git-backed, workspace-scoped memory for the DeepSeek Harness (global layer + per-workspace layers)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/tasks.js CHANGED
@@ -151,7 +151,7 @@ const STATE_MEANING = {
151
151
  [TASK_STATES.STOPPED]: 'was armed, and its owning session stopped, so the host deleted it',
152
152
  [TASK_STATES.FAILED]: 'was due and has still not delivered; the host records no failure of its own',
153
153
  [TASK_STATES.ORPHANED]: 'armed, but the session that owns it cannot be resolved, so it can never fire',
154
- [TASK_STATES.SKIPPED]: 'seen in the live store but not captured, because its kind is not one we can read',
154
+ [TASK_STATES.SKIPPED]: 'captured, but its kind is not one we can re-arm; a restore refuses it rather than guessing',
155
155
  [TASK_STATES.DONE]: 'delivered, or its recurrence is exhausted',
156
156
  }
157
157
 
@@ -397,12 +397,16 @@ export function readScheduleStore(path) {
397
397
  * deleted on purpose must stay visible as gone rather than vanish from the record of what this
398
398
  * machine used to run.
399
399
  *
400
- * A task whose kind we do not recognise is SKIPPED, not written: storing it would mean restoring
401
- * it later without understanding it. It is reported, so the omission is visible.
400
+ * A task whose kind we do not recognise is stored and flagged SKIPPED: dropping it would mean losing
401
+ * a task from a backup, and flagging it means a restore refuses it rather than guessing.
402
402
  */
403
- export function backupTasks(root, { schedulePath, now = Date.now(), nowIso } = {}) {
403
+ export function backupTasks(root, { schedulePath, now = Date.now(), nowIso, sessions } = {}) {
404
404
  const at = nowIso ?? new Date(now).toISOString()
405
405
  const live = readScheduleStore(schedulePath ?? defaultSchedulePath())
406
+ // Read once per pass, not per task: it is a directory walk, and every row asks the same question.
407
+ // Injectable so a test can state the world instead of inheriting this machine's: `null` means
408
+ // the question cannot be answered and no row is called orphaned.
409
+ const sessionSet = sessions === undefined ? sessionIdsOnDisk() : sessions
406
410
  const before = listTasks(root)
407
411
  if (live.error !== null) {
408
412
  // The index is still rewritten, so a page from an earlier pass cannot sit on disk describing a
@@ -420,7 +424,13 @@ export function backupTasks(root, { schedulePath, now = Date.now(), nowIso } = {
420
424
  const record = task.record
421
425
  if (!record || typeof record !== 'object') { skipped.push({ id: task.id, reason: 'no record' }); continue }
422
426
  if (!KNOWN_KINDS.includes(String(record.kind))) {
427
+ // CAPTURED, not dropped. An earlier version skipped these entirely, which meant a task whose
428
+ // kind we did not recognise silently vanished from a BACKUP - the one job this layer has. It
429
+ // is written and flagged 'skipped' instead, and planRestore refuses that state, so it can
430
+ // never be blindly re-armed. Preserved, visible, and unrestorable: all three on purpose.
423
431
  skipped.push({ id: task.id, reason: 'unrecognised kind: ' + String(record.kind) })
432
+ writeTask(root, record, TASK_STATES.SKIPPED, byKey.get(contentKey(record))?.backedUpAt ?? at)
433
+ seen.add(contentKey(record))
424
434
  continue
425
435
  }
426
436
  const key = contentKey(record)
@@ -429,7 +439,11 @@ export function backupTasks(root, { schedulePath, now = Date.now(), nowIso } = {
429
439
  // A held-back task stays held back across every backup: that state is the user's decision, and
430
440
  // a routine pass must not overwrite it with a derived one.
431
441
  const held = previous && previous.stateKnown && previous.state === TASK_STATES.PAUSED
432
- const derived = deriveState({ stored: held ? TASK_STATES.PAUSED : null, live: task, now })
442
+ // Without this the ORPHANED state exists in the vocabulary and is never produced by any real
443
+ // pass - reachable from a unit test and from nothing else. `sessions === null` means the
444
+ // sessions directory is absent and we cannot tell, so the row is left as active.
445
+ const ownerResolvable = sessionSet === null || task.sessionId === undefined ? true : sessionSet.has(task.sessionId)
446
+ const derived = deriveState({ stored: held ? TASK_STATES.PAUSED : null, live: task, now, ownerResolvable })
433
447
  writeTask(root, record, derived.state, previous ? previous.backedUpAt : at)
434
448
  if (previous) refreshed += 1
435
449
  else written += 1
@@ -542,3 +556,37 @@ export function planRestore(root, { liveTasks = [], now = Date.now() } = {}) {
542
556
  }
543
557
  return { add, present, paused, refused, unreadable }
544
558
  }
559
+
560
+ /** The directory DSH keeps session records in, beside the schedule store's home. */
561
+ export function sessionsRoot(home = homedir()) {
562
+ const configured = process.env.DSH_HOME
563
+ const dshHome = configured && configured !== '' ? configured : join(home, '.dsh')
564
+ return join(dshHome, 'sessions')
565
+ }
566
+
567
+ /**
568
+ * Every session id that still has a record on disk.
569
+ *
570
+ * Used to tell ORPHANED from ACTIVE. A live task is armed against the session that created it, and
571
+ * the runtime resolves an agent from that id before every delivery (schedule runtime.ts:101) - so
572
+ * a task whose session is gone is a row that will never fire while still occupying the store. The
573
+ * backup cannot see the runtime, but it can see whether the session still exists.
574
+ *
575
+ * @returns a Set, or null when the sessions directory is absent
576
+ *
577
+ * Null rather than an empty set on purpose: no sessions directory means we cannot tell, and an
578
+ * empty set would mark every task on the machine orphaned. Refusing to guess is the rule here.
579
+ */
580
+ export function sessionIdsOnDisk(home = homedir()) {
581
+ const root = sessionsRoot(home)
582
+ if (!existsSync(root)) return null
583
+ const ids = new Set()
584
+ let projects
585
+ try { projects = readdirSync(root) } catch { return null }
586
+ for (const project of projects) {
587
+ let sessions
588
+ try { sessions = readdirSync(join(root, project)) } catch { continue }
589
+ for (const id of sessions) ids.add(id)
590
+ }
591
+ return ids
592
+ }