@straiffi/archon 1.11.9 → 1.11.10

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 (92) hide show
  1. package/dist/client/assets/{ActivityView-CgC2rnNq.js → ActivityView-RnE0983a.js} +1 -1
  2. package/dist/client/assets/{GitHubActionsDialog-XXGwm-bw.js → GitHubActionsDialog-PaNJhVzv.js} +1 -1
  3. package/dist/client/assets/{VerificationDialog-C8cDKxSG.js → VerificationDialog-PGrCJ1DJ.js} +1 -1
  4. package/dist/client/assets/{architectureDiagram-3BPJPVTR-BhdIU_o9.js → architectureDiagram-3BPJPVTR-CKd1Dmpz.js} +1 -1
  5. package/dist/client/assets/{blockDiagram-GPEHLZMM-CfKNdNso.js → blockDiagram-GPEHLZMM-M3OjRhA0.js} +1 -1
  6. package/dist/client/assets/{c4Diagram-AAUBKEIU-BYOg4iNZ.js → c4Diagram-AAUBKEIU-VWBcGTXO.js} +1 -1
  7. package/dist/client/assets/channel-CBQk1IQf.js +1 -0
  8. package/dist/client/assets/{chunk-2J33WTMH-JmBb9p2l.js → chunk-2J33WTMH-C4WpCUzU.js} +1 -1
  9. package/dist/client/assets/{chunk-3OPIFGDE-CuCn8uDI.js → chunk-3OPIFGDE-BQ-j87T6.js} +1 -1
  10. package/dist/client/assets/{chunk-5ZQYHXKU-DaGbZz7d.js → chunk-5ZQYHXKU-BCZvyT9u.js} +1 -1
  11. package/dist/client/assets/{chunk-727SXJPM-D7UwchWL.js → chunk-727SXJPM-DZP_PMNz.js} +1 -1
  12. package/dist/client/assets/{chunk-AQP2D5EJ-CxYpis5S.js → chunk-AQP2D5EJ-Cejit8wh.js} +1 -1
  13. package/dist/client/assets/{chunk-CSCIHK7Q-DF4Jo5lo.js → chunk-CSCIHK7Q-CvXC4rkf.js} +1 -1
  14. package/dist/client/assets/{chunk-KSCS5N6A-Bul_8QaO.js → chunk-KSCS5N6A-hTgJWrr3.js} +1 -1
  15. package/dist/client/assets/{chunk-L5ZTLDWV-BQvBZt_1.js → chunk-L5ZTLDWV-bSK1mut3.js} +1 -1
  16. package/dist/client/assets/{chunk-LZXEDZCA-BPsejGww.js → chunk-LZXEDZCA-Y2hnSpRr.js} +2 -2
  17. package/dist/client/assets/{chunk-ND2GUHAM-D8AX2HAv.js → chunk-ND2GUHAM-BiHH5lS5.js} +1 -1
  18. package/dist/client/assets/{chunk-NZK2D7GU-CE21a8CK.js → chunk-NZK2D7GU-DdxcwTTc.js} +1 -1
  19. package/dist/client/assets/{chunk-O5CBEL6O-vZQ8YCvm.js → chunk-O5CBEL6O-BZIbgKJr.js} +1 -1
  20. package/dist/client/assets/{chunk-WU5MYG2G-BpBNiw0j.js → chunk-WU5MYG2G-D3yvqt9P.js} +1 -1
  21. package/dist/client/assets/classDiagram-4FO5ZUOK-BjizLQoS.js +1 -0
  22. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-B6qpU-kO.js +1 -0
  23. package/dist/client/assets/{dagre-BM42HDAG-BqEPuJwq.js → dagre-BM42HDAG-CZQZPoaH.js} +1 -1
  24. package/dist/client/assets/{diagram-2AECGRRQ-BWLSNrm2.js → diagram-2AECGRRQ-DU7zsX_b.js} +1 -1
  25. package/dist/client/assets/{diagram-5GNKFQAL-CUNUlEy4.js → diagram-5GNKFQAL-BCCoHKlp.js} +1 -1
  26. package/dist/client/assets/{diagram-KO2AKTUF-CgZcDZ0J.js → diagram-KO2AKTUF-BR_GNJLN.js} +1 -1
  27. package/dist/client/assets/{diagram-LMA3HP47-DTfMha7_.js → diagram-LMA3HP47-B6LZOUTc.js} +1 -1
  28. package/dist/client/assets/{diagram-OG6HWLK6-ClO5evmB.js → diagram-OG6HWLK6-w89pjkBe.js} +1 -1
  29. package/dist/client/assets/{erDiagram-TEJ5UH35-BRW1HE33.js → erDiagram-TEJ5UH35-BReR8ngL.js} +1 -1
  30. package/dist/client/assets/{flowDiagram-I6XJVG4X-tC2xALm9.js → flowDiagram-I6XJVG4X-CGwpJ9iQ.js} +1 -1
  31. package/dist/client/assets/{ganttDiagram-6RSMTGT7-D7Bu6hss.js → ganttDiagram-6RSMTGT7-BcOPz4K9.js} +1 -1
  32. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-Cx1uYcGX.js → gitGraphDiagram-PVQCEYII-m6-x38H5.js} +1 -1
  33. package/dist/client/assets/{index-CArcyEjQ.js → index-yUckGfMu.js} +119 -113
  34. package/dist/client/assets/{infoDiagram-5YYISTIA-bKT-ZR4K.js → infoDiagram-5YYISTIA-11iWCl_6.js} +1 -1
  35. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CrUBKkGR.js → ishikawaDiagram-YF4QCWOH-DbeJ14eh.js} +1 -1
  36. package/dist/client/assets/{journeyDiagram-JHISSGLW-CCmXkk8M.js → journeyDiagram-JHISSGLW-BYFdU9SL.js} +1 -1
  37. package/dist/client/assets/{kanban-definition-UN3LZRKU-BmcV7QM-.js → kanban-definition-UN3LZRKU-Dyc52bol.js} +1 -1
  38. package/dist/client/assets/{line-C5oH-IfR.js → line-CBauYkto.js} +1 -1
  39. package/dist/client/assets/{mermaid-parser.core-BFyFUctK.js → mermaid-parser.core-BBbL8arn.js} +1 -1
  40. package/dist/client/assets/{mermaid.core-DXkHU1mX.js → mermaid.core-CB2mIiiP.js} +3 -3
  41. package/dist/client/assets/{mindmap-definition-RKZ34NQL-DhErnbAy.js → mindmap-definition-RKZ34NQL-CvWYGfWn.js} +1 -1
  42. package/dist/client/assets/{pieDiagram-4H26LBE5-B8pkGeer.js → pieDiagram-4H26LBE5-BsbIF0rU.js} +1 -1
  43. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-BkYWz3t_.js → quadrantDiagram-W4KKPZXB-Bvs09UnJ.js} +1 -1
  44. package/dist/client/assets/{requirementDiagram-4Y6WPE33-DB6jC89I.js → requirementDiagram-4Y6WPE33-B3U5xSYZ.js} +1 -1
  45. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-BeAslLcb.js → sankeyDiagram-5OEKKPKP-Dds8cy2g.js} +1 -1
  46. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-BgXC1aKi.js → sequenceDiagram-3UESZ5HK-BoMb08Ay.js} +1 -1
  47. package/dist/client/assets/{stateDiagram-AJRCARHV-Dj3Qlk0A.js → stateDiagram-AJRCARHV-atm7kYDg.js} +1 -1
  48. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-EYJsus1Q.js +1 -0
  49. package/dist/client/assets/{timeline-definition-PNZ67QCA-C8ncLV_K.js → timeline-definition-PNZ67QCA-C7U--kCB.js} +1 -1
  50. package/dist/client/assets/{vennDiagram-CIIHVFJN-jgBSpqC1.js → vennDiagram-CIIHVFJN-BoPWwPSI.js} +1 -1
  51. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-DfGD5Kys.js → wardleyDiagram-YWT4CUSO-CJ6Y7g3b.js} +1 -1
  52. package/dist/client/assets/{xychartDiagram-2RQKCTM6-Dyk41E2n.js → xychartDiagram-2RQKCTM6-rN5shXue.js} +1 -1
  53. package/dist/client/index.html +1 -1
  54. package/dist/server/db.js +2 -0
  55. package/dist/server/db.js.map +1 -1
  56. package/dist/server/index.js +88 -175
  57. package/dist/server/index.js.map +1 -1
  58. package/dist/server/lib/bundleCleanupManagement.js +181 -0
  59. package/dist/server/lib/bundleCleanupManagement.js.map +1 -0
  60. package/dist/server/lib/bundleCleanupRoutes.js +36 -0
  61. package/dist/server/lib/bundleCleanupRoutes.js.map +1 -0
  62. package/dist/server/lib/bundleDeletion.js +257 -0
  63. package/dist/server/lib/bundleDeletion.js.map +1 -0
  64. package/dist/server/lib/bundleDeletionRoutes.js +73 -0
  65. package/dist/server/lib/bundleDeletionRoutes.js.map +1 -0
  66. package/dist/server/lib/bundleDeletionSchema.js +134 -0
  67. package/dist/server/lib/bundleDeletionSchema.js.map +1 -0
  68. package/dist/server/lib/bundleDeletionStore.js +18 -0
  69. package/dist/server/lib/bundleDeletionStore.js.map +1 -0
  70. package/dist/server/lib/bundleWorktreeCleanup.js +160 -0
  71. package/dist/server/lib/bundleWorktreeCleanup.js.map +1 -0
  72. package/dist/server/lib/dockerSandboxCleanup.js +68 -0
  73. package/dist/server/lib/dockerSandboxCleanup.js.map +1 -0
  74. package/dist/server/lib/dockerSandboxEnvironmentOperations.js +4 -3
  75. package/dist/server/lib/dockerSandboxEnvironmentOperations.js.map +1 -1
  76. package/dist/server/lib/dockerSandboxExecutionEnvironment.js +8 -55
  77. package/dist/server/lib/dockerSandboxExecutionEnvironment.js.map +1 -1
  78. package/dist/server/lib/executionEnvironmentOperations.js +7 -2
  79. package/dist/server/lib/executionEnvironmentOperations.js.map +1 -1
  80. package/dist/server/lib/executionEnvironmentReconciliation.js +34 -169
  81. package/dist/server/lib/executionEnvironmentReconciliation.js.map +1 -1
  82. package/dist/server/lib/executionEnvironmentTargetCleanup.js +232 -0
  83. package/dist/server/lib/executionEnvironmentTargetCleanup.js.map +1 -0
  84. package/dist/server/lib/executionEnvironments.js +1 -0
  85. package/dist/server/lib/executionEnvironments.js.map +1 -1
  86. package/dist/server/lib/i18n/resources.js +10 -0
  87. package/dist/server/lib/i18n/resources.js.map +1 -1
  88. package/package.json +1 -1
  89. package/dist/client/assets/channel-4wFtrIng.js +0 -1
  90. package/dist/client/assets/classDiagram-4FO5ZUOK-DNAwGSF5.js +0 -1
  91. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-cskOKMdS.js +0 -1
  92. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-I1Ry2lsa.js +0 -1
@@ -8,6 +8,10 @@ import { createHash, randomUUID } from 'crypto';
8
8
  import cors from 'cors';
9
9
  import { adjectives, animals, uniqueNamesGenerator } from 'unique-names-generator';
10
10
  import db from './db.js';
11
+ import { registerBundleDeletionRoutes } from './lib/bundleDeletionRoutes.js';
12
+ import { registerBundleCleanupRoutes } from './lib/bundleCleanupRoutes.js';
13
+ import { attemptBundleDeletion, beginBundleDeletion, BundleDeletionConflict, resumeBundleDeletions } from './lib/bundleDeletion.js';
14
+ import { isBundleDeletionBranchReserved } from './lib/bundleDeletionStore.js';
11
15
  import { pruneAllRepositoryGraphScans } from './lib/repositoryGraphRetention.js';
12
16
  import { registerRepositoryGraphRoutes } from './lib/repositoryGraphRoutes.js';
13
17
  import { registerWorkspaceDiffRoutes } from './lib/workspaceDiffRoutes.js';
@@ -105,6 +109,7 @@ import { buildImportedIssueSessionTitle, importProviderHostedIssueImages } from
105
109
  import { dispatchGitHubWorkflow, getGitHubWorkflowDispatchConfig, getGitHubWorkflowRun, getGitHubWorkflowRunJobs, listGitHubWorkflows, listGitHubWorkflowRuns, } from './lib/integrations/githubActions.js';
106
110
  import { clearAllGitHubActionsSessions, getActiveTrackedRunCount, getTrackedGitHubActionsRuns, startGitHubActionsPolling, trackGitHubActionsRun, } from './lib/integrations/githubActionsSessions.js';
107
111
  import { reconcileStartupExecutions } from './lib/executionReconciliation.js';
112
+ import { deleteArchivedAbandonedProjectEnvironments } from './lib/executionEnvironmentTargetCleanup.js';
108
113
  import { cleanupProjectExecutionEnvironments, cleanupExecutionEnvironmentsForTarget, ExecutionEnvironmentCleanupError, reconcileOrphanedExecutionEnvironments, reconcileExecutionEnvironments, reconcileExecutionEnvironmentLifecycleOperations, reconcileProductionExecutionEnvironmentBindings, } from './lib/executionEnvironmentReconciliation.js';
109
114
  import { createExecutionTargetKey, resolveExecutionTarget, resolveProjectRootExecutionTarget } from './lib/executionTargets.js';
110
115
  import { getExecutionEnvironmentContextForRun } from './lib/executionEnvironmentContexts.js';
@@ -657,52 +662,28 @@ const deleteTicketRecord = async (ticket) => {
657
662
  }
658
663
  const bundledProject = isBundledTicket(ticket) ? getEffectiveProject(ticket) : null;
659
664
  const deletedBundleId = ticket.worktree_bundle_id;
660
- const deletedBundle = bundledProject && deletedBundleId ? getBundle(deletedBundleId, bundledProject.id) : null;
661
- const bundleResourcesWereCleaned = Boolean(bundledProject
662
- && deletedBundle
663
- && !isProjectRootBundle(deletedBundle)
664
- && countBundleTickets(deletedBundle.id, bundledProject.id) === 1);
665
- if (bundleResourcesWereCleaned && bundledProject && deletedBundle) {
666
- try {
667
- await cleanupWorktreeExecutionTarget({
668
- project: bundledProject,
669
- targetKind: 'bundle-worktree',
670
- targetKey: createExecutionTargetKey(bundledProject.id, {
671
- kind: 'bundle-worktree',
672
- bundleId: deletedBundle.id,
673
- branch: deletedBundle.branch,
674
- }),
675
- targetLabel: 'bundle worktree',
676
- worktreeTarget: 'bundle',
677
- branch: deletedBundle.branch,
678
- });
679
- }
680
- catch (error) {
681
- return {
682
- ok: false,
683
- status: error instanceof HttpError ? error.status : 500,
684
- error: error instanceof Error ? error.message : 'Unable to delete the bundle worktree',
685
- };
686
- }
687
- }
665
+ let bundleCleanup;
688
666
  let nextBuildTicketId = null;
689
667
  let nextBuildSequenceId = null;
690
668
  let affectedTicketIds = [];
691
- if (ticket.state === 'build') {
692
- const sequenceRemoval = removeTicketFromBuildSequence(ticket.id);
693
- if (sequenceRemoval.ok) {
694
- nextBuildTicketId = sequenceRemoval.nextTicketId;
695
- nextBuildSequenceId = sequenceRemoval.sequenceId;
696
- affectedTicketIds = sequenceRemoval.affectedTicketIds;
697
- }
698
- }
699
669
  try {
700
670
  withExecutionRunTransaction(transaction => {
701
671
  assertTicketServiceTargetMutable(ticket.id);
672
+ if (ticket.state === 'build') {
673
+ const sequenceRemoval = removeTicketFromBuildSequence(ticket.id);
674
+ if (sequenceRemoval.ok) {
675
+ nextBuildTicketId = sequenceRemoval.nextTicketId;
676
+ nextBuildSequenceId = sequenceRemoval.sequenceId;
677
+ affectedTicketIds = sequenceRemoval.affectedTicketIds;
678
+ }
679
+ }
702
680
  if (ticket.project_id) {
703
681
  deleteTerminalReviewRepairIterationsForTicketInTransaction(transaction, ticket.project_id, ticket.id);
704
682
  }
705
683
  db.prepare('DELETE FROM tickets WHERE id = ?').run(ticket.id);
684
+ if (bundledProject && deletedBundleId) {
685
+ bundleCleanup = cleanupBundleIfEmpty(deletedBundleId, bundledProject);
686
+ }
706
687
  }, io, 'immediate');
707
688
  }
708
689
  catch (error) {
@@ -718,18 +699,7 @@ const deleteTicketRecord = async (ticket) => {
718
699
  catch (error) {
719
700
  console.warn(`[ticket-delete] Unable to remove stage snapshots for ticket ${ticket.id}`, error);
720
701
  }
721
- if (bundledProject && deletedBundleId) {
722
- try {
723
- await cleanupBundleIfEmpty(deletedBundleId, bundledProject, { resourcesAlreadyCleaned: bundleResourcesWereCleaned });
724
- }
725
- catch (error) {
726
- return {
727
- ok: false,
728
- status: error instanceof HttpError ? error.status : 500,
729
- error: error instanceof Error ? error.message : 'Unable to clean up the empty bundle',
730
- };
731
- }
732
- }
702
+ await finishAutomaticBundleDeletion(bundleCleanup);
733
703
  io.emit('ticket:deleted', { id: ticket.id });
734
704
  return { ok: true, nextBuildTicketId, nextBuildSequenceId, affectedTicketIds };
735
705
  };
@@ -980,6 +950,9 @@ const uniqueBundleBranchName = (name, projectId, options = {}) => {
980
950
  let branch = base;
981
951
  let suffix = 2;
982
952
  const conflictsWithBranch = (candidate) => {
953
+ if (isBundleDeletionBranchReserved(projectId, candidate)) {
954
+ return true;
955
+ }
983
956
  const bundleConflict = getBundleByBranch(candidate, projectId);
984
957
  if (bundleConflict && bundleConflict.id !== options.currentBundle?.id) {
985
958
  return true;
@@ -1034,7 +1007,7 @@ const createAutoBundleForName = (name, projectId, baseBranch = null) => {
1034
1007
  }
1035
1008
  return bundle;
1036
1009
  };
1037
- const cleanupBundleIfEmpty = async (bundleId, project, options = {}) => {
1010
+ const cleanupBundleIfEmpty = (bundleId, project) => {
1038
1011
  if (!bundleId) {
1039
1012
  return;
1040
1013
  }
@@ -1045,24 +1018,41 @@ const cleanupBundleIfEmpty = async (bundleId, project, options = {}) => {
1045
1018
  if (isProjectRootBundle(bundle)) {
1046
1019
  return;
1047
1020
  }
1048
- if (hasActiveBundleCheckWork(project.id, bundle.id)) {
1049
- throw new HttpError(409, 'Cannot delete a bundle while check work is active');
1021
+ const assertEligible = () => {
1022
+ if (hasActiveBundleCheckWork(project.id, bundle.id)) {
1023
+ throw new BundleDeletionConflict('Cannot delete a bundle while check work is active');
1024
+ }
1025
+ if (hasActiveBundleSpecWork(project.id, bundle.id)) {
1026
+ throw new BundleDeletionConflict('Cannot delete a bundle while Spec work is active');
1027
+ }
1028
+ if (hasActiveBundleRepositoryGraphScan(project.id, bundle.id)) {
1029
+ throw new BundleDeletionConflict('Cannot delete a bundle while a repository graph scan is active');
1030
+ }
1031
+ };
1032
+ const chatSessionIds = listChatSessions(project.id)
1033
+ .filter(session => session.target_bundle_id === bundle.id).map(session => session.id);
1034
+ const specSessionIds = listSpecSessions(project.id)
1035
+ .filter(session => session.target_bundle_id === bundle.id).map(session => session.id);
1036
+ beginBundleDeletion({ project, bundleId: bundle.id, assertEligible });
1037
+ return { projectId: project.id, bundleId: bundle.id, chatSessionIds, specSessionIds };
1038
+ };
1039
+ const finishAutomaticBundleDeletion = async (cleanup) => {
1040
+ if (!cleanup) {
1041
+ return;
1050
1042
  }
1051
- if (!options.resourcesAlreadyCleaned) {
1052
- await cleanupWorktreeExecutionTarget({
1053
- project,
1054
- targetKind: 'bundle-worktree',
1055
- targetKey: createExecutionTargetKey(project.id, {
1056
- kind: 'bundle-worktree',
1057
- bundleId: bundle.id,
1058
- branch: bundle.branch,
1059
- }),
1060
- targetLabel: 'bundle worktree',
1061
- worktreeTarget: 'bundle',
1062
- branch: bundle.branch,
1063
- });
1043
+ for (const sessionId of cleanup.chatSessionIds) {
1044
+ const session = getProjectChatSession(cleanup.projectId, sessionId);
1045
+ if (session) {
1046
+ io.emit('chat-session:updated', session);
1047
+ }
1048
+ }
1049
+ for (const sessionId of cleanup.specSessionIds) {
1050
+ const session = getProjectSpecSession(cleanup.projectId, sessionId);
1051
+ if (session) {
1052
+ io.emit('spec-session:updated', session);
1053
+ }
1064
1054
  }
1065
- deleteBundle(bundle.id, project.id);
1055
+ await attemptBundleDeletion(cleanup.projectId, cleanup.bundleId);
1066
1056
  };
1067
1057
  const asProjectPayload = (body) => {
1068
1058
  if (!body || typeof body !== 'object' || Array.isArray(body)) {
@@ -4223,6 +4213,9 @@ app.post('/bundles', async (req, res) => {
4223
4213
  });
4224
4214
  }
4225
4215
  if (requestedBranch) {
4216
+ if (isBundleDeletionBranchReserved(result.project.id, branch)) {
4217
+ return res.status(409).json({ error: 'This branch is reserved while a deleted bundle is being cleaned up. Please try again after cleanup finishes.' });
4218
+ }
4226
4219
  if (getBundleByBranch(branch, result.project.id) || hasBundleBranchCaseConflict(branch, result.project.id)) {
4227
4220
  return res.status(409).json({ error: 'Bundle branch already exists in this project' });
4228
4221
  }
@@ -4257,76 +4250,24 @@ app.post('/bundles', async (req, res) => {
4257
4250
  }
4258
4251
  res.status(201).json(serializeBundleRow(result.project.id, bundle, config.tool ?? 'opencode', [], result.project));
4259
4252
  });
4260
- app.delete('/bundles/:id', async (req, res) => {
4261
- const result = getRequiredProject(req);
4262
- if ('error' in result) {
4263
- return res.status(400).json({ error: result.error });
4264
- }
4265
- const bundle = getBundle(req.params.id, result.project.id);
4266
- if (!bundle) {
4267
- return res.status(404).json({ error: 'Bundle not found' });
4268
- }
4269
- if (isProjectRootBundle(bundle)) {
4270
- return res.status(409).json({ error: 'Project root cannot be deleted' });
4271
- }
4272
- if (hasActiveBundleCheckWork(result.project.id, bundle.id)) {
4273
- return res.status(409).json({ error: 'Cannot delete a bundle while check work is active' });
4274
- }
4275
- if (hasActiveBundleSpecWork(result.project.id, bundle.id)) {
4276
- return res.status(409).json({ error: 'Cannot delete a bundle while Spec work is active' });
4277
- }
4278
- if (hasActiveBundleRepositoryGraphScan(result.project.id, bundle.id)) {
4279
- return res.status(409).json({ error: 'Cannot delete a bundle while a repository graph scan is active' });
4280
- }
4281
- if (countBundleTickets(bundle.id, result.project.id) > 0) {
4282
- return res.status(409).json({ error: 'Cannot delete a bundle that is still used by tickets' });
4283
- }
4284
- if (result.project.active_target_kind === 'bundle' && result.project.active_target_bundle_id === bundle.id) {
4285
- return res.status(409).json({ error: 'Cannot delete the currently selected target bundle' });
4286
- }
4287
- const affectedChatSessionIds = listChatSessions(result.project.id)
4288
- .filter(chatSession => chatSession.target_bundle_id === bundle.id)
4289
- .map(chatSession => chatSession.id);
4290
- const affectedSpecSessionIds = listSpecSessions(result.project.id)
4291
- .filter(specSession => specSession.target_bundle_id === bundle.id)
4292
- .map(specSession => specSession.id);
4293
- let cleanupResult;
4294
- try {
4295
- cleanupResult = await cleanupWorktreeExecutionTarget({
4296
- project: result.project,
4297
- targetKind: 'bundle-worktree',
4298
- targetKey: createExecutionTargetKey(result.project.id, {
4299
- kind: 'bundle-worktree',
4300
- bundleId: bundle.id,
4301
- branch: bundle.branch,
4302
- }),
4303
- targetLabel: 'bundle worktree',
4304
- worktreeTarget: 'bundle',
4305
- branch: bundle.branch,
4306
- });
4307
- }
4308
- catch (error) {
4309
- return res.status(error instanceof HttpError ? error.status : 500).json({
4310
- error: error instanceof Error ? error.message : 'Unable to delete the bundle worktree',
4311
- });
4312
- }
4313
- deleteBundle(bundle.id, result.project.id);
4314
- affectedChatSessionIds.forEach(chatSessionId => {
4315
- const updatedChatSession = getProjectChatSession(result.project.id, chatSessionId);
4316
- if (updatedChatSession) {
4317
- io.emit('chat-session:updated', updatedChatSession);
4253
+ registerBundleCleanupRoutes(app);
4254
+ registerBundleDeletionRoutes(app, {
4255
+ getRequiredProject,
4256
+ hasActiveCheckWork: hasActiveBundleCheckWork,
4257
+ hasActiveSpecWork: hasActiveBundleSpecWork,
4258
+ hasActiveGraphScan: hasActiveBundleRepositoryGraphScan,
4259
+ emitChatSessionUpdate: (projectId, sessionId) => {
4260
+ const session = getProjectChatSession(projectId, sessionId);
4261
+ if (session) {
4262
+ io.emit('chat-session:updated', session);
4318
4263
  }
4319
- });
4320
- affectedSpecSessionIds.forEach(specSessionId => {
4321
- const updatedSpecSession = getProjectSpecSession(result.project.id, specSessionId);
4322
- if (updatedSpecSession) {
4323
- io.emit('spec-session:updated', updatedSpecSession);
4264
+ },
4265
+ emitSpecSessionUpdate: (projectId, sessionId) => {
4266
+ const session = getProjectSpecSession(projectId, sessionId);
4267
+ if (session) {
4268
+ io.emit('spec-session:updated', session);
4324
4269
  }
4325
- });
4326
- if (cleanupResult.branchWarning) {
4327
- return res.status(200).json({ warning: cleanupResult.branchWarning });
4328
- }
4329
- res.status(204).end();
4270
+ },
4330
4271
  });
4331
4272
  app.patch('/bundles/:id', async (req, res) => {
4332
4273
  const result = getRequiredProject(req);
@@ -8532,6 +8473,9 @@ const assertProjectDeletionEligible = (projectId, claimId) => {
8532
8473
  if (projectCount <= 1) {
8533
8474
  throw new ProjectDeletionError(409, 'Cannot delete the last remaining project');
8534
8475
  }
8476
+ if (db.prepare("SELECT 1 FROM bundle_deletion_jobs WHERE project_id = ? AND status IN ('pending', 'blocked') LIMIT 1").get(projectId)) {
8477
+ throw new ProjectDeletionError(409, 'Cannot delete a project while bundle workspace cleanup is pending');
8478
+ }
8535
8479
  if (countInboundProjectLinks(projectId) > 0) {
8536
8480
  throw new ProjectDeletionError(409, 'Cannot delete a project that is linked from another project');
8537
8481
  }
@@ -8634,7 +8578,9 @@ const completeClaimedProjectDeletion = async (projectId, claimId) => {
8634
8578
  SET parent_run_id = NULL, retry_of_run_id = NULL
8635
8579
  WHERE project_id = ?
8636
8580
  `).run(projectId);
8581
+ deleteArchivedAbandonedProjectEnvironments(projectId);
8637
8582
  db.prepare("DELETE FROM execution_environments WHERE project_id = ? AND lifecycle_state = 'retired'").run(projectId);
8583
+ db.prepare("DELETE FROM bundle_deletion_jobs WHERE project_id = ? AND status = 'completed'").run(projectId);
8638
8584
  const deleted = db.prepare('DELETE FROM projects WHERE id = ? AND deletion_claim_id = ?').run(projectId, claimId);
8639
8585
  if (deleted.changes !== 1) {
8640
8586
  throw new ProjectDeletionError(409, 'Project deletion ownership was lost');
@@ -8900,34 +8846,7 @@ app.patch('/tickets/:id', async (req, res) => {
8900
8846
  }
8901
8847
  }
8902
8848
  const bundleChanged = previousBundleId !== resolvedBundleId || ticket.project_id !== nextProjectId;
8903
- let previousBundleResourcesCleaned = false;
8904
- if (bundleChanged && previousBundleId && previousProject) {
8905
- const previousBundle = getBundle(previousBundleId, previousProject.id);
8906
- if (previousBundle
8907
- && !isProjectRootBundle(previousBundle)
8908
- && countBundleTickets(previousBundle.id, previousProject.id) === 1) {
8909
- try {
8910
- await cleanupWorktreeExecutionTarget({
8911
- project: previousProject,
8912
- targetKind: 'bundle-worktree',
8913
- targetKey: createExecutionTargetKey(previousProject.id, {
8914
- kind: 'bundle-worktree',
8915
- bundleId: previousBundle.id,
8916
- branch: previousBundle.branch,
8917
- }),
8918
- targetLabel: 'bundle worktree',
8919
- worktreeTarget: 'bundle',
8920
- branch: previousBundle.branch,
8921
- });
8922
- previousBundleResourcesCleaned = true;
8923
- }
8924
- catch (error) {
8925
- return res.status(error instanceof HttpError ? error.status : 500).json({
8926
- error: error instanceof Error ? error.message : 'Unable to clean up the empty bundle',
8927
- });
8928
- }
8929
- }
8930
- }
8849
+ let bundleCleanup;
8931
8850
  try {
8932
8851
  db.transaction(() => {
8933
8852
  if (bundleChanged) {
@@ -8937,28 +8856,20 @@ app.patch('/tickets/:id', async (req, res) => {
8937
8856
  deleteTicketDependenciesForTicket(ticket.id);
8938
8857
  }
8939
8858
  db.prepare('UPDATE tickets SET title = ?, description = ?, model = ?, variant = ?, skills_json = ?, worktree_bundle_id = ?, project_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(title?.trim() ?? ticket.title, description ?? ticket.description, resolvedModel, resolvedVariant, JSON.stringify(resolvedSkills), resolvedBundleId, nextProjectId, req.params.id);
8859
+ if (bundleChanged && previousBundleId && previousProject) {
8860
+ bundleCleanup = cleanupBundleIfEmpty(previousBundleId, previousProject);
8861
+ }
8940
8862
  }).immediate();
8941
8863
  }
8942
8864
  catch (error) {
8943
- if (error instanceof HttpError) {
8944
- return res.status(error.status).json({ error: error.message });
8865
+ if (error instanceof HttpError || error instanceof BundleDeletionConflict) {
8866
+ return res.status(error instanceof HttpError ? error.status : 409).json({ error: error.message });
8945
8867
  }
8946
8868
  throw error;
8947
8869
  }
8948
8870
  upsertTicketDescriptionMessage(req.params.id, description ?? ticket.description);
8949
8871
  const updated = getTicket(req.params.id);
8950
- if (bundleChanged && previousBundleId && previousProject) {
8951
- try {
8952
- await cleanupBundleIfEmpty(previousBundleId, previousProject, {
8953
- resourcesAlreadyCleaned: previousBundleResourcesCleaned,
8954
- });
8955
- }
8956
- catch (error) {
8957
- return res.status(error instanceof HttpError ? error.status : 500).json({
8958
- error: error instanceof Error ? error.message : 'Unable to clean up the empty bundle',
8959
- });
8960
- }
8961
- }
8872
+ await finishAutomaticBundleDeletion(bundleCleanup);
8962
8873
  io.emit('ticket:updated', updated);
8963
8874
  res.json(updated);
8964
8875
  });
@@ -10682,6 +10593,7 @@ export const runExecutionArtifactCleanup = (mode) => {
10682
10593
  if (mode === 'periodic') {
10683
10594
  await resumeInterruptedProjectDeletions();
10684
10595
  }
10596
+ await resumeBundleDeletions();
10685
10597
  const orphanedEnvironments = await reconcileOrphanedExecutionEnvironments();
10686
10598
  if (orphanedEnvironments.scanned > 0) {
10687
10599
  console.log(`Reconciled ${orphanedEnvironments.cleaned} orphaned execution environment(s); ${orphanedEnvironments.failed} cleanup(s) retained for retry`);
@@ -10760,6 +10672,7 @@ const runExecutionEnvironmentLifecycleReconciliation = () => {
10760
10672
  isProjectOwnedInMemory: projectId => getActiveRunStatusForProject(projectId) !== null,
10761
10673
  })
10762
10674
  .then(() => reconcileExecutionEnvironmentLifecycleOperations())
10675
+ .then(() => resumeBundleDeletions())
10763
10676
  .then(() => undefined)
10764
10677
  .catch(error => {
10765
10678
  console.error('Failed to reconcile execution environment lifecycle operations', error);