dsh-delete-session 0.2.1 → 0.2.2

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": "dsh-delete-session",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Delete sessions from the DSH Web sidebar: adds a Delete action to the session row menu with a risk-consent dialog, sends the session log, workspace accounting and live session state to the system trash (or removes them outright), and, when opted in, quarantines the files the conversation produced.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -117,6 +117,10 @@ function collectDescendants(headers, sessionId) {
117
117
  const byParent = new Map()
118
118
  for (const header of headers) {
119
119
  if (typeof header.parentSession !== 'string') continue
120
+ // Only subagents belong to a parent. A fork records where it branched from,
121
+ // but it is a conversation of its own and the user can open it like any other:
122
+ // sweeping those up deletes work nobody asked to delete.
123
+ if (header.origin !== 'subagent') continue
120
124
  const key = bareSessionId(header.parentSession)
121
125
  const list = byParent.get(key)
122
126
  if (list === undefined) byParent.set(key, [header])
@@ -144,7 +148,10 @@ function collectDescendants(headers, sessionId) {
144
148
  */
145
149
  function collectOrphans(headers) {
146
150
  const present = new Set(headers.map((entry) => bareSessionId(entry.id)))
147
- return headers.filter((entry) => typeof entry.parentSession === 'string'
151
+ // Same rule as the descendant walk: a fork whose source is gone is still a
152
+ // conversation of its own, not an abandoned child.
153
+ return headers.filter((entry) => entry.origin === 'subagent'
154
+ && typeof entry.parentSession === 'string'
148
155
  && !present.has(bareSessionId(entry.parentSession)))
149
156
  }
150
157
 
@@ -770,11 +777,27 @@ async function deleteSession(ctx, sessionId, options) {
770
777
  const children = []
771
778
  for (const childId of childSessionIds) {
772
779
  try {
780
+ // A subagent can still be running while its parent is deleted. Removing the
781
+ // log out from under it is what made the agent's next write fail with ENOENT,
782
+ // so a child gets the same guard as the parent: stop it, flush it, and wait
783
+ // for the write lease before a single byte moves.
784
+ await quiesceAgent(ctx, childId)
785
+ await flushAndDetach(ctx, childId)
786
+ await assertNoActiveWriter(ctx, childId)
773
787
  const childFiles = await retireSessionFiles(childId, recycle)
774
788
  await withWorkspaceLock(() => detachFromWorkspace(ctx, childId))
775
789
  children.push({ id: childId, removed: childFiles.removed, caches: childFiles.caches })
776
790
  } catch (error) {
777
- children.push({ id: childId, removed: 0, caches: 0, error: String((error && error.message) || error) })
791
+ // Never delete a child that could not be quieted: something is still writing
792
+ // to it. Report it so the caller can try again once it settles.
793
+ const busy = error instanceof HttpError && error.code === 'busy'
794
+ children.push({
795
+ id: childId,
796
+ removed: 0,
797
+ caches: 0,
798
+ skipped: busy ? 'busy' : 'error',
799
+ error: String((error && error.message) || error),
800
+ })
778
801
  }
779
802
  }
780
803
 
@@ -954,7 +954,13 @@ test('the switch decides between staging for the bin and removing outright', asy
954
954
 
955
955
  // --- subagent sessions are separate sessions, linked by parentSession ------
956
956
 
957
- const header = (id, parentSession) => (parentSession === undefined ? { id } : { id, parentSession })
957
+ /** A session with no parent at all. */
958
+ const header = (id) => ({ id })
959
+ /** A real subagent: DSH marks these with `origin`, and they belong to their parent. */
960
+ const child = (id, parentSession) => ({ id, parentSession, origin: 'subagent', delegationDepth: 1 })
961
+ /** A fork: it records where it branched from, but it is a conversation of its own. */
962
+ const fork = (id, parentSession) => ({ id, parentSession, isSeeded: true, delegationDepth: 0 })
963
+
958
964
 
959
965
  function fakeContext(services) {
960
966
  return { get: (name) => services[name] }
@@ -963,8 +969,8 @@ function fakeContext(services) {
963
969
  test('a delete finds the grandchildren behind a child, not just the children', () => {
964
970
  const headers = [
965
971
  header('session-parent'),
966
- header('session-child', 'session-parent'),
967
- header('session-grandchild', 'session-child'),
972
+ child('session-child', 'session-parent'),
973
+ child('session-grandchild', 'session-child'),
968
974
  ]
969
975
 
970
976
  const found = internals.collectDescendants(headers, 'session-parent')
@@ -976,7 +982,7 @@ test('a delete finds the grandchildren behind a child, not just the children', (
976
982
  test('a parent link matches whether or not either id carries the prefix', () => {
977
983
  const headers = [
978
984
  header('session-parent'),
979
- header('child-uuid', 'parent-uuid'),
985
+ child('child-uuid', 'parent-uuid'),
980
986
  ]
981
987
 
982
988
  const found = internals.collectDescendants(headers, 'parent-uuid')
@@ -987,7 +993,7 @@ test('a parent link matches whether or not either id carries the prefix', () =>
987
993
  test('a session that names itself as its parent does not loop forever', () => {
988
994
  const headers = [
989
995
  header('session-parent'),
990
- header('session-child', 'session-child'),
996
+ child('session-child', 'session-child'),
991
997
  ]
992
998
 
993
999
  const found = internals.collectDescendants(headers, 'session-parent')
@@ -995,11 +1001,36 @@ test('a session that names itself as its parent does not loop forever', () => {
995
1001
  assert.deepEqual(found, [], 'a self-parented session is not its own descendant')
996
1002
  })
997
1003
 
1004
+ test('a forked conversation is not a child of the conversation it branched from', () => {
1005
+ const headers = [
1006
+ header('session-parent'),
1007
+ fork('session-fork', 'session-parent'),
1008
+ ]
1009
+
1010
+ const found = internals.collectDescendants(headers, 'session-parent')
1011
+
1012
+ assert.deepEqual(found, [], 'a fork is a visible conversation, not a subagent')
1013
+ })
1014
+
1015
+ test('a fork is never offered in the dialog, so it can never be ticked', () => {
1016
+ const headers = [
1017
+ header('session-parent'),
1018
+ fork('session-fork', 'session-parent'),
1019
+ child('session-real-child', 'session-parent'),
1020
+ ]
1021
+
1022
+ const listed = internals.describeChildren(headers, 'session-parent')
1023
+
1024
+ assert.deepEqual(listed.map((c) => c.id), ['session-real-child'])
1025
+ const picked = internals.selectChildDeletes(headers, 'session-parent', ['session-fork', 'session-real-child'])
1026
+ assert.deepEqual(picked, ['session-real-child'], 'even a forged fork id is dropped')
1027
+ })
1028
+
998
1029
  test('an orphan is a session whose parent is gone', () => {
999
1030
  const headers = [
1000
1031
  header('session-parent'),
1001
- header('session-kept', 'session-parent'),
1002
- header('session-orphan', 'session-deleted-long-ago'),
1032
+ child('session-kept', 'session-parent'),
1033
+ child('session-orphan', 'session-deleted-long-ago'),
1003
1034
  ]
1004
1035
 
1005
1036
  const orphans = internals.collectOrphans(headers)
@@ -1036,8 +1067,8 @@ test('a host that can list nothing yields no headers rather than throwing', asyn
1036
1067
  test('the child list carries a depth so nesting is visible in the dialog', () => {
1037
1068
  const headers = [
1038
1069
  header('session-parent'),
1039
- { id: 'session-child', parentSession: 'session-parent', cwd: 'E:\\work' },
1040
- { id: 'session-grandchild', parentSession: 'session-child', cwd: 'E:\\work' },
1070
+ { ...child('session-child', 'session-parent'), cwd: 'E:\\work' },
1071
+ { ...child('session-grandchild', 'session-child'), cwd: 'E:\\work' },
1041
1072
  ]
1042
1073
 
1043
1074
  const children = internals.describeChildren(headers, 'session-parent')
@@ -1051,8 +1082,8 @@ test('the child list carries a depth so nesting is visible in the dialog', () =>
1051
1082
  test('only the ids the user picked, and only real descendants, are deleted', () => {
1052
1083
  const headers = [
1053
1084
  header('session-parent'),
1054
- header('session-child', 'session-parent'),
1055
- header('session-grandchild', 'session-child'),
1085
+ child('session-child', 'session-parent'),
1086
+ child('session-grandchild', 'session-child'),
1056
1087
  header('session-unrelated'),
1057
1088
  ]
1058
1089
 
@@ -1068,8 +1099,8 @@ test('only the ids the user picked, and only real descendants, are deleted', ()
1068
1099
  test('a picked child is matched by its bare uuid but deleted under its own id', () => {
1069
1100
  const headers = [
1070
1101
  header('session-parent'),
1071
- header('session-grandchild', 'session-child'),
1072
- header('session-child', 'session-parent'),
1102
+ child('session-grandchild', 'session-child'),
1103
+ child('session-child', 'session-parent'),
1073
1104
  ]
1074
1105
 
1075
1106
  // The dialog may hold either spelling; the delete always uses the header's own.
@@ -1139,3 +1170,48 @@ test('the delete reports which children failed', async () => {
1139
1170
 
1140
1171
  assert.equal(result.children.length, 0, 'an invalid id never becomes a child delete')
1141
1172
  })
1173
+
1174
+ test('a child is probed for a live writer before its files go', async () => {
1175
+ const parent = plantSession(PARENT_ID)
1176
+ const child = plantSession(CHILD_ID)
1177
+ const probed = []
1178
+
1179
+ // A host service that answers the write-lease probe for whoever asks.
1180
+ const ctx = {
1181
+ get: (name) => (name === 'sessionPersistence'
1182
+ ? {
1183
+ open: async (id) => { probed.push(id); return { close: async () => {} } },
1184
+ stat: async () => undefined,
1185
+ }
1186
+ : undefined),
1187
+ }
1188
+
1189
+ await internals.deleteSession(ctx, PARENT_ID, { recycle: false, childSessionIds: [CHILD_ID] })
1190
+
1191
+ assert.ok(probed.includes(CHILD_ID), 'the child gets the same write-lease probe the parent gets')
1192
+ assert.equal(fs.existsSync(child.dir), false, 'and it is deleted once the probe comes back clear')
1193
+ })
1194
+
1195
+ test('a child that is still open is left alone and reported as skipped', async () => {
1196
+ const parent = plantSession(PARENT_ID)
1197
+ const child = plantSession(CHILD_ID)
1198
+
1199
+ // Busy on every attempt: the guard gives up and the child must survive.
1200
+ const ctx = {
1201
+ get: (name) => (name === 'sessionPersistence'
1202
+ ? {
1203
+ open: async (id) => {
1204
+ if (id === CHILD_ID) throw new Error('session is busy: another writer holds it')
1205
+ return { close: async () => {} }
1206
+ },
1207
+ stat: async () => undefined,
1208
+ }
1209
+ : undefined),
1210
+ }
1211
+
1212
+ const result = await internals.deleteSession(ctx, PARENT_ID, { recycle: false, childSessionIds: [CHILD_ID] })
1213
+
1214
+ assert.equal(fs.existsSync(parent.dir), false, 'the parent still goes')
1215
+ assert.equal(fs.existsSync(child.dir), true, 'a busy child is not deleted under its writer')
1216
+ assert.equal(result.children[0].skipped, 'busy', 'and the report says why')
1217
+ })