@try-works/dsh-memory 0.1.13 → 0.1.15

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": "@try-works/dsh-memory",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
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/host.js CHANGED
@@ -648,20 +648,24 @@ export function wireHost(ctx, store, cfg) {
648
648
  * store is left exactly as it is, matched by CONTENT because create() mints a fresh id
649
649
  * (schedule index.ts:256) and ids therefore cannot identify anything across a restore.
650
650
  */
651
- async function maybeRestoreTasks(agent) {
652
- if (restoreAttempted) return
653
- if (!agent || isSubagentSession(agent.session)) return
651
+ async function maybeRestoreTasks(agent, { force = false } = {}) {
652
+ // EVERY early return REPORTS WHY. These used to return silently, and the tool that calls this
653
+ // reported success regardless - so a manual restore after the boot pass returned immediately at
654
+ // the once-per-process guard while telling the caller it had restored. An action that does
655
+ // nothing and says it worked is worse than one that fails.
656
+ if (restoreAttempted && !force) return { ran: false, reason: 'already restored in this process; pass force to run it again' }
657
+ if (!agent || isSubagentSession(agent.session)) return { ran: false, reason: 'a restored task needs a main session to own it' }
654
658
  const sessionId = agent.session && agent.session.id
655
- if (!sessionId) return
659
+ if (!sessionId) return { ran: false, reason: 'the session has no id to bind the task to' }
656
660
  const schedule = ctx.get('schedule')
657
- if (!schedule || typeof schedule.create !== 'function') return
661
+ if (!schedule || typeof schedule.create !== 'function') return { ran: false, reason: 'the schedule service is unavailable' }
658
662
  restoreAttempted = true
659
663
  try {
660
664
  const path = cfg.schedulePath || defaultSchedulePath()
661
665
  const live = readScheduleStore(path)
662
666
  if (live.error !== null) {
663
667
  logRun(store.root, 'tasks-restore', { ok: false, error: live.error })
664
- return
668
+ return { ran: true, owner: sessionId, error: live.error }
665
669
  }
666
670
  const plan = planRestore(store.root, { liveTasks: live.tasks })
667
671
  const added = []
@@ -684,8 +688,10 @@ export function wireHost(ctx, store, cfg) {
684
688
  failed: failed.length,
685
689
  detail: failed.length ? failed : undefined,
686
690
  })
691
+ return { ran: true, owner: sessionId, added: added.length, present: plan.present.length, paused: plan.paused.length, refused: plan.refused.length, failed: failed.length }
687
692
  } catch (error) {
688
693
  logRun(store.root, 'tasks-restore', { ok: false, error: String((error && error.message) || error).slice(0, 160) })
694
+ return { ran: true, owner: sessionId, error: String((error && error.message) || error).slice(0, 160) }
689
695
  }
690
696
  }
691
697
 
package/src/tasks.js CHANGED
@@ -56,14 +56,22 @@ export const TASK_STATES = {
56
56
  * without one.
57
57
  */
58
58
  export const TASK_STATE_EXITS = {
59
- active: ['stopped', 'done', 'failed'],
60
- pending: ['active'],
59
+ // Two entries were missing, and NOTHING noticed until writeTask began reading this table.
60
+ //
61
+ // 'orphaned': the session holding a live task can disappear while the task itself does not
62
+ // change, so an armed task can become one that can never fire.
63
+ //
64
+ // 'paused' in every row: holding a task back is a DECISION the user makes about a task in any
65
+ // condition, not a lifecycle step. Writing the table without it left the entire pause path
66
+ // undeclared - the feature worked, and the declaration that was supposed to describe it did not.
67
+ active: ['stopped', 'done', 'failed', 'orphaned', 'paused'],
68
+ pending: ['active', 'paused'],
61
69
  paused: ['pending'],
62
- stopped: ['pending'],
63
- failed: ['active'],
64
- orphaned: ['pending'],
65
- skipped: ['active'],
66
- done: ['active'],
70
+ stopped: ['pending', 'paused'],
71
+ failed: ['active', 'paused'],
72
+ orphaned: ['pending', 'paused'],
73
+ skipped: ['active', 'paused'],
74
+ done: ['active', 'paused'],
67
75
  }
68
76
 
69
77
  const STATES = Object.values(TASK_STATES)
@@ -183,6 +191,18 @@ function ensureTasksDir(root) {
183
191
  export function writeTask(root, record, state, nowIso) {
184
192
  ensureTasksDir(root)
185
193
  const file = taskFilePath(root, record)
194
+ // ENFORCED, not documented. This table sat in the file unread since it was written, so a state
195
+ // could move anywhere and nothing objected - the same shape as the vocabulary states that had a
196
+ // test and no producer. Refusing here means a task cannot silently leave a state by a route
197
+ // nobody declared, which is what makes 'every resting state has a working exit' checkable
198
+ // rather than aspirational.
199
+ const previous = readTaskFile(file)
200
+ if (previous && previous.stateKnown && previous.state !== state) {
201
+ const allowed = TASK_STATE_EXITS[previous.state] ?? []
202
+ if (!allowed.includes(state)) {
203
+ throw new Error('tasks: refusing the transition ' + previous.state + ' -> ' + state + ' for "' + String(record.title ?? '') + '"; allowed from ' + previous.state + ': ' + allowed.join(', '))
204
+ }
205
+ }
186
206
  writeFileSync(file, JSON.stringify({ backedUpAt: nowIso, state, record }, null, 2) + '\n', 'utf8')
187
207
  return file;
188
208
  }
package/src/tools.js CHANGED
@@ -368,8 +368,11 @@ export function registerTools(ctx, store, extras = {}) {
368
368
  changed = { written: r.written, refreshed: r.refreshed, stopped: r.stopped, skipped: r.skipped.length, error: r.error }
369
369
  } else if (action === 'restore') {
370
370
  if (!tasksRestore) throw new Error('memory_tasks: the restore is unavailable in this context')
371
- await tasksRestore(exec.agent)
372
- changed = { restored: true }
371
+ // FORCED, because the once-per-process guard has almost certainly already been consumed by
372
+ // the boot restore - the manual action exists to run it again on demand.
373
+ // The outcome is REPORTED rather than assumed: this used to say { restored: true } whatever
374
+ // happened, including when nothing did.
375
+ changed = await tasksRestore(exec.agent, { force: true })
373
376
  } else if (action === 'pause' || action === 'resume') {
374
377
  if (!args.title) throw new Error('memory_tasks: ' + action + ' needs a title')
375
378
  const { tasks } = listTasks(store.root)