@try-works/dsh-memory 0.1.10 → 0.1.12

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 (3) hide show
  1. package/STATUS.md +29 -1
  2. package/package.json +1 -1
  3. package/src/tasks.js +54 -4
package/STATUS.md CHANGED
@@ -76,6 +76,27 @@ How to tell loaded from not: ask the host. `memory_recover` used to reply *"unav
76
76
  context"* and now replies *"returned invalid output: value is not lossless JSON"*. The change of
77
77
  ERROR is the proof the first fix is loaded and the tool now executes — a different failure, one
78
78
  layer further along. The report fix in the last row is what remains pending.
79
+ ### The tasks layer (new since the last restart)
80
+
81
+ A fifth layer, `tasks`, global only: `global/tasks/` holds one JSON file per DSH scheduled task
82
+ plus a regenerated `index.md`. It backs up the schedule store, which is otherwise the only copy of
83
+ your automated tasks and is in no git repository. The host DELETES a task when its owning session
84
+ stops, so a task can vanish with nothing to observe; the backup runs on the sync cadence and a
85
+ restore re-arms what is missing at the first session after boot.
86
+
87
+ Verified on disk: 386 tests pass. `backupTasks` pointed at `E:\dsh-memory` wrote the layer for
88
+ real — a second pass reported `written=0`, so it converges. `planRestore` against that layer and
89
+ the live `schedule.json` returned `add=0 present=2`, which is the correct answer rather than an
90
+ empty one: both tasks are in the live store, so a restore finds nothing to do.
91
+
92
+ NOT verified until a restart: that the plugin itself runs either pass. The cadence
93
+ (`maybeBackupTasks`) and the first-session restore (`maybeRestoreTasks`) have only been exercised
94
+ through the wiring tests, where the schedule service is a stub and the store is temporary. Writing
95
+ those files by calling the module is not the same claim as the plugin writing them.
96
+
97
+ Known limitation, by design: the host deletes a task when its owning session stops, so a restored
98
+ task lives as long as the session that adopted it. The boot restore is what re-arms them.
99
+
79
100
  ## NOT VERIFIED — treat these as unknown
80
101
 
81
102
  | item | what is actually known | what would settle it |
@@ -105,6 +126,12 @@ the flag on did not change what the user sees.
105
126
 
106
127
  ### Blocked on a restart
107
128
 
129
+ - [ ] **Restart, then check the tasks layer.** It writes to ANOTHER plugin's storage through
130
+ that plugin's service, so it deserves a real observation rather than a passing suite. After
131
+ the restart, ask for `memory_tasks` and expect a report with `live: 2`, `backedUp: 2` and
132
+ `pending.present: 2`. Then check `logs/memory.log` for a `tasks-backup` entry and a
133
+ `tasks-restore` entry: the first should say `refreshed: 2`, the second `present: 2, added: 0`.
134
+ An `added` above zero would mean de-duplication failed and it duplicated a task.
108
135
  - [ ] **Restart again.** The previous restart worked: `memory_recover` went from "unavailable in
109
136
  this context" to executing. It now fails one layer further on, at output validation, which
110
137
  is fixed on disk. Confirm by calling `memory_recover({})` and getting a report, not an error.
@@ -156,7 +183,8 @@ Kept because the pattern matters more than the list: every one of these was foun
156
183
  | A test edit silently discarded | my own script re-read and overwrote the file before writing |
157
184
  ## Published
158
185
 
159
- **`@try-works/dsh-memory@0.1.0`** on npm. The unscoped name `dsh-memory` was already taken by an
186
+ **`@try-works/dsh-memory@0.1.10`** on npm (0.1.0 was the first release; the Memory tab and the
187
+ tasks layer each added several). The unscoped name `dsh-memory` was already taken by an
160
188
  unrelated DeepSeek Harness memory plugin, so the package publishes under the existing `@try-works`
161
189
  scope.
162
190
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@try-works/dsh-memory",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
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
@@ -22,6 +22,7 @@ import { createHash } from 'node:crypto'
22
22
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
23
23
  import { homedir } from 'node:os'
24
24
  import { join } from 'node:path'
25
+ import { globalDir } from './store.js'
25
26
  import { slugify } from './workspace-key.js'
26
27
 
27
28
  /** Directory, under a scope root, holding one file per task plus the index. */
@@ -78,8 +79,15 @@ export function readState(value) {
78
79
  return STATES.includes(value) ? value : null;
79
80
  }
80
81
 
81
- /** The directory holding the task files for a scope root. */
82
- export function tasksDir(root) { return join(root, TASKS_DIR) }
82
+ /**
83
+ * Where the task files live: under the GLOBAL scope, not the store root.
84
+ *
85
+ * The layer is global-only by design - DSH tasks are not repository-scoped - so it belongs beside
86
+ * global/semantic.md and the rest. Resolving it against the root instead put it at <root>/tasks,
87
+ * a directory nothing else in the store knows about and no index or sync rule covers. Caught by
88
+ * looking for global/tasks in a real store and finding nothing there.
89
+ */
90
+ export function tasksDir(root) { return join(globalDir(root), TASKS_DIR) }
83
91
 
84
92
  /**
85
93
  * The fields that say WHEN a task runs. Used to tell one task from another.
@@ -392,9 +400,13 @@ export function readScheduleStore(path) {
392
400
  * A task whose kind we do not recognise is SKIPPED, not written: storing it would mean restoring
393
401
  * it later without understanding it. It is reported, so the omission is visible.
394
402
  */
395
- export function backupTasks(root, { schedulePath, now = Date.now(), nowIso } = {}) {
403
+ export function backupTasks(root, { schedulePath, now = Date.now(), nowIso, sessions } = {}) {
396
404
  const at = nowIso ?? new Date(now).toISOString()
397
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
398
410
  const before = listTasks(root)
399
411
  if (live.error !== null) {
400
412
  // The index is still rewritten, so a page from an earlier pass cannot sit on disk describing a
@@ -421,7 +433,11 @@ export function backupTasks(root, { schedulePath, now = Date.now(), nowIso } = {
421
433
  // A held-back task stays held back across every backup: that state is the user's decision, and
422
434
  // a routine pass must not overwrite it with a derived one.
423
435
  const held = previous && previous.stateKnown && previous.state === TASK_STATES.PAUSED
424
- const derived = deriveState({ stored: held ? TASK_STATES.PAUSED : null, live: task, now })
436
+ // Without this the ORPHANED state exists in the vocabulary and is never produced by any real
437
+ // pass - reachable from a unit test and from nothing else. `sessions === null` means the
438
+ // sessions directory is absent and we cannot tell, so the row is left as active.
439
+ const ownerResolvable = sessionSet === null || task.sessionId === undefined ? true : sessionSet.has(task.sessionId)
440
+ const derived = deriveState({ stored: held ? TASK_STATES.PAUSED : null, live: task, now, ownerResolvable })
425
441
  writeTask(root, record, derived.state, previous ? previous.backedUpAt : at)
426
442
  if (previous) refreshed += 1
427
443
  else written += 1
@@ -534,3 +550,37 @@ export function planRestore(root, { liveTasks = [], now = Date.now() } = {}) {
534
550
  }
535
551
  return { add, present, paused, refused, unreadable }
536
552
  }
553
+
554
+ /** The directory DSH keeps session records in, beside the schedule store's home. */
555
+ export function sessionsRoot(home = homedir()) {
556
+ const configured = process.env.DSH_HOME
557
+ const dshHome = configured && configured !== '' ? configured : join(home, '.dsh')
558
+ return join(dshHome, 'sessions')
559
+ }
560
+
561
+ /**
562
+ * Every session id that still has a record on disk.
563
+ *
564
+ * Used to tell ORPHANED from ACTIVE. A live task is armed against the session that created it, and
565
+ * the runtime resolves an agent from that id before every delivery (schedule runtime.ts:101) - so
566
+ * a task whose session is gone is a row that will never fire while still occupying the store. The
567
+ * backup cannot see the runtime, but it can see whether the session still exists.
568
+ *
569
+ * @returns a Set, or null when the sessions directory is absent
570
+ *
571
+ * Null rather than an empty set on purpose: no sessions directory means we cannot tell, and an
572
+ * empty set would mark every task on the machine orphaned. Refusing to guess is the rule here.
573
+ */
574
+ export function sessionIdsOnDisk(home = homedir()) {
575
+ const root = sessionsRoot(home)
576
+ if (!existsSync(root)) return null
577
+ const ids = new Set()
578
+ let projects
579
+ try { projects = readdirSync(root) } catch { return null }
580
+ for (const project of projects) {
581
+ let sessions
582
+ try { sessions = readdirSync(join(root, project)) } catch { continue }
583
+ for (const id of sessions) ids.add(id)
584
+ }
585
+ return ids
586
+ }