From 37d68d9440c3110aa5377b7e51b32700f3201530 Mon Sep 17 00:00:00 2001 From: ZhouChuange Date: Tue, 26 May 2026 19:53:07 +1000 Subject: [PATCH 1/3] fix(archive): pure-export semantics + auto-unarchive migration (#169) Archive used to combine 'export to Markdown' with a soft-hide that removed the session from the sidebar. Users expected archive to mean 'snapshot the conversation as a Markdown file', not 'hide the conversation'. The old soft-hide also made the 'click again to un-archive' code path unreachable from the UI because hidden sessions never reappear in the list. Changes: - session-store.js archive(id): drop unarchive branch, drop archived=true write, drop sessionId reset. Only render to Markdown + toast. - session-store.js constructor: one-shot idempotent migration flips every previously-hidden session back to visible, gated by archiveSemanticsV2Migrated flag in globalState. Errors are swallowed so a failed migration cannot block activation. After upgrade, sessions hidden by the old archive action reappear in the sidebar. Repeated archive on the same session produces multiple Markdown snapshots (timestamp + _writeUnique suffix on collision). --- src/chat/session-store.js | 91 ++++++++++++++++++++++----------------- 1 file changed, 51 insertions(+), 40 deletions(-) diff --git a/src/chat/session-store.js b/src/chat/session-store.js index 13b91c4..da536cb 100644 --- a/src/chat/session-store.js +++ b/src/chat/session-store.js @@ -102,6 +102,40 @@ class SessionStore { this._getBusy = getBusy; // (id) => bool (is that session's run busy?) this._onDeleteRun = onDeleteRun; // (id) => void (abort + remove from _runs) this.sessionId = null; // currently displayed session id (null = empty view) + + // Issue #169: archive semantics changed from "soft-hide + export" to + // "pure export". Sessions that were previously hidden by the old + // archive action are stuck invisible in globalState. Run a one-shot + // idempotent migration that flips every `archived: true` back to + // `false` so those records reappear in the sidebar after upgrade. + // Guarded by a globalState boolean so we only do this once per user. + // Fire-and-forget: any postList() call after the next tick will see + // the migrated data. + this._migrateArchivedFlagIfNeeded(); + } + + /** + * One-time migration for issue #169. Idempotent: subsequent runs no-op + * because the `archiveSemanticsV2Migrated` flag is set on first success. + * Errors are swallowed (logged via console.warn) — failure here must not + * block extension activation. + */ + async _migrateArchivedFlagIfNeeded() { + try { + if (this._gs.get('deepseekAgent.archiveSemanticsV2Migrated', false)) return; + const list = this.all(); + let touched = false; + for (const s of list) { + if (s.archived) { s.archived = false; touched = true; } + } + if (touched) await this.set(list); + await this._gs.update('deepseekAgent.archiveSemanticsV2Migrated', true); + if (touched) this.postList(); + } catch (err) { + // Non-fatal: next launch will retry. + // eslint-disable-next-line no-console + console.warn('[deep-copilot] archive-v2 migration failed:', err && err.message || err); + } } // ─── Raw storage ──────────────────────────────────────────────────────── @@ -361,43 +395,28 @@ class SessionStore { } /** - * "Archive" a session — issue #165. + * "Archive" a session — issues #165, #169. * - * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`, - * disappear from the sidebar list, data stays in globalState. Users - * reported that this didn't match the menu label's promise: nothing was - * actually *archived* anywhere they could see, find, or grep. + * Behaviour evolution: + * pre-#165 : soft-hide toggle (flip `archived`, disappear from list). + * #165/#166: render to Markdown + soft-hide (export AND hide). + * #169 : pure export. Render to Markdown, leave session state + * completely untouched — it stays in the sidebar, stays + * the active session, can be archived again to produce + * another snapshot. * - * New behaviour: render the session to Markdown and write it under - * /.deep-copilot/archives/yyyyMMdd-HHmmss-.md - * Then perform the original soft-hide so the session leaves the sidebar. + * Contract: + * - exportSessionToMarkdown returns absolute path → toast + done. + * - returns null (user cancelled folder picker / save dialog) → silent. + * - throws → toast error; no state change. * - * The "un-archive" gesture (clicking again on an already-archived item) - * is still supported via the boolean toggle — it simply un-hides the - * record without touching the Markdown file on disk. + * Session state (`archived` flag, current sessionId, list ordering) is + * never mutated by this method. */ async archive(id) { - const list = this.all(); - const s = list.find(x => x.id === id); + const s = this.all().find(x => x.id === id); if (!s) return; - // Un-archive: just flip back to visible. No file I/O needed. - if (s.archived) { - s.archived = false; - await this.set(list); - this.postList(); - return; - } - - // Archive: render to Markdown FIRST. Only hide the session from the - // sidebar if we actually produced a file on disk — otherwise setting - // `archived = true` on export failure (or on a user-cancelled save - // dialog) would leave the session invisible in the list while nothing - // was actually archived. Contract: - // - savedPath is a string → write succeeded, hide the session. - // - savedPath is null → user cancelled the save dialog; keep - // session visible, do nothing more. - // - throw caught below → fs error; surface message, keep visible. let savedPath = null; try { const { exportSessionToMarkdown } = require('./archive-export'); @@ -405,17 +424,9 @@ class SessionStore { } catch (err) { const msg = (err && err.message) || String(err); vscode.window.showErrorMessage(tf('archiveFailed', { msg })); - return; // do not toggle archived — keep state consistent - } - if (!savedPath) return; // user cancelled; keep state consistent - - s.archived = true; - if (this.sessionId === id) { - this.sessionId = null; - this._post({ type: 'sessionLoaded', id: null, messages: [] }); + return; } - await this.set(list); - this.postList(); + if (!savedPath) return; // user cancelled this._notifyArchived(savedPath); } From 5b168321bb73b04350d5716d98c34883be68c9b5 Mon Sep 17 00:00:00 2001 From: ZhouChuange <zhouchaunge@gmail.com> Date: Tue, 26 May 2026 19:59:41 +1000 Subject: [PATCH 2/3] =?UTF-8?q?=EF=BB=BFfix(agent-loop):=20keep=20watchdog?= =?UTF-8?q?=20turn=20alive=20for=20monitored=20bg=20jobs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the model watches a bg job (e.g. long training) that was started in an earlier turn, run._sessionStartedBgJobs is empty and the BG_WAIT_SKIPPED_MODEL_DONE guard ends the turn right after the model says "I will keep monitoring" -- silently dropping the watchdog. Track jobs the model inspects via read_terminal(terminal: "deepseek-job-*") in run._monitoredBgJobs. The guard now also refuses to end the turn while any monitored job is still active, so the loop keeps polling/snapshotting until completion or the 4h per-turn budget. --- src/chat/agent-loop.js | 24 ++++++++++++++++++++++-- src/chat/tool-executor.js | 14 ++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/chat/agent-loop.js b/src/chat/agent-loop.js index 032fa05..66b57e3 100644 --- a/src/chat/agent-loop.js +++ b/src/chat/agent-loop.js @@ -282,6 +282,12 @@ class AgentLoop { // ctx.registerBgJob into bg-shell so each freshly started jobId is // recorded here; we clear entries on end so memory is bounded. run._sessionStartedBgJobs = new Set(); + // Bg jobs the model has actively inspected via read_terminal in this run. + // Populated by tool-executor; consumed by the BG_WAIT_SKIPPED_MODEL_DONE + // guard so the turn refuses to end while a monitored job is still alive + // (e.g. user asked the model to watch a training that was started in an + // earlier turn — `_sessionStartedBgJobs` alone wouldn't catch this). + run._monitoredBgJobs = new Set(); // Only accept job-end events for jobs that belong to THIS session. // Require an exact sessionId match so events without a sessionId (orphaned // terminals not registered via addActiveBgJob) are also dropped — this @@ -289,7 +295,10 @@ class AgentLoop { const _bgJobEndHandler = (payload) => { if (!payload || payload.sessionId !== sid) return; run._pendingBgJobEvents.push(payload); - if (payload.jobId) run._sessionStartedBgJobs.delete(payload.jobId); + if (payload.jobId) { + run._sessionStartedBgJobs.delete(payload.jobId); + run._monitoredBgJobs.delete(payload.jobId); + } }; onBgJobEnded(_bgJobEndHandler); @@ -642,10 +651,21 @@ class AgentLoop { const _activeJobsNow = myBgJobs(); const _hasOwnRunningJob = [...run._sessionStartedBgJobs] .some(j => _activeJobsNow.has(j)); - if (!_hasPendingEvents && !_hasOwnRunningJob) { + // Watchdog scenario: model is actively monitoring a + // pre-existing bg job (e.g. training started in an + // earlier turn). Detected when the model has called + // read_terminal(terminal: "deepseek-job-X") in this + // run. If any such monitored job is still active, + // refuse to end the turn — the model committed to + // watching it through completion. + const _monitored = (run._monitoredBgJobs instanceof Set) ? run._monitoredBgJobs : null; + const _hasMonitoredRunningJob = !!_monitored && [..._monitored] + .some(j => _activeJobsNow.has(j)); + if (!_hasPendingEvents && !_hasOwnRunningJob && !_hasMonitoredRunningJob) { Logger.info('BG_WAIT_SKIPPED_MODEL_DONE', { jobs: [..._activeJobsNow], sessionStartedJobs: [...run._sessionStartedBgJobs], + monitoredJobs: _monitored ? [..._monitored] : [], }); break; } diff --git a/src/chat/tool-executor.js b/src/chat/tool-executor.js index 1f8abc8..2047133 100644 --- a/src/chat/tool-executor.js +++ b/src/chat/tool-executor.js @@ -532,6 +532,20 @@ class ToolExecutor { if (!(run._sessionStartedBgJobs instanceof Set)) run._sessionStartedBgJobs = new Set(); run._sessionStartedBgJobs.add(jobId); }; + // Watchdog/monitor detection: when the model inspects an existing + // deepseek-job-* terminal via read_terminal in this run, treat that + // as an active monitoring commitment. agent-loop's + // BG_WAIT_SKIPPED_MODEL_DONE guard refuses to end the turn while a + // monitored job is still alive — preventing the model from + // "promising to keep watching" and then immediately stopping when + // the underlying job was spawned in a previous turn. + if (name === 'read_terminal' && run && args && typeof args.terminal === 'string') { + const _jobId = args.terminal; + if (/^deepseek-job-/.test(_jobId)) { + if (!(run._monitoredBgJobs instanceof Set)) run._monitoredBgJobs = new Set(); + run._monitoredBgJobs.add(_jobId); + } + } if (tcId) { ctx.onStreamDelta = (delta) => { if (!delta) return; From 451fb6a01bbc8f59b57c4f420bdbadc0df8b2a31 Mon Sep 17 00:00:00 2001 From: ZhouChuange <zhouchaunge@gmail.com> Date: Tue, 26 May 2026 20:08:08 +1000 Subject: [PATCH 3/3] =?UTF-8?q?=EF=BB=BFfix(session-store):=20use=20Logger?= =?UTF-8?q?=20for=20migration=20failures;=20clarify=20async-migration=20co?= =?UTF-8?q?mment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review on PR #170: - Replace console.warn (and the no-console eslint suppression) in the archive-v2 migration error path with Logger.info('ARCHIVE_V2_MIGRATION_FAILED'). Diagnostics now respect deepseekAgent.enableDebugLog and surface in the "Deep Copilot Debug" output channel. - Reword the constructor comment: the migration is fire-and-forget and triggers postList() itself on completion; the sidebar refreshes when the migrated data lands, not necessarily on the next tick. --- src/chat/session-store.js | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/chat/session-store.js b/src/chat/session-store.js index da536cb..6bd941a 100644 --- a/src/chat/session-store.js +++ b/src/chat/session-store.js @@ -8,6 +8,7 @@ const vscode = require('vscode'); const { randomBytes } = require('crypto'); const { t, tf } = require('../utils/i18n'); +const { Logger } = require('../logger'); // ─── Orphan tool_calls sanitizer ─────────────────────────────────────────── // Removes ANY incomplete assistant{tool_calls} group from a message array, @@ -109,16 +110,19 @@ class SessionStore { // idempotent migration that flips every `archived: true` back to // `false` so those records reappear in the sidebar after upgrade. // Guarded by a globalState boolean so we only do this once per user. - // Fire-and-forget: any postList() call after the next tick will see - // the migrated data. + // Fire-and-forget: the migration runs asynchronously and triggers + // postList() itself once it finishes, so the sidebar refreshes as + // soon as the migrated data is persisted (not necessarily on the + // very next tick). this._migrateArchivedFlagIfNeeded(); } /** * One-time migration for issue #169. Idempotent: subsequent runs no-op * because the `archiveSemanticsV2Migrated` flag is set on first success. - * Errors are swallowed (logged via console.warn) — failure here must not - * block extension activation. + * Errors are swallowed (logged via Logger) — failure here must not + * block extension activation; the next launch will retry automatically + * because the flag was never written. */ async _migrateArchivedFlagIfNeeded() { try { @@ -132,9 +136,13 @@ class SessionStore { await this._gs.update('deepseekAgent.archiveSemanticsV2Migrated', true); if (touched) this.postList(); } catch (err) { - // Non-fatal: next launch will retry. - // eslint-disable-next-line no-console - console.warn('[deep-copilot] archive-v2 migration failed:', err && err.message || err); + // Non-fatal: next launch will retry. Route through Logger so the + // diagnostic respects deepseekAgent.enableDebugLog and lands in + // the "Deep Copilot Debug" output channel/log file. + Logger.info('ARCHIVE_V2_MIGRATION_FAILED', { + message: (err && err.message) || String(err), + stack: (err && err.stack) || undefined, + }); } }