Skip to content

Commit 3de6184

Browse files
renezander030claude
andcommitted
operator-deck: re-arm a dismissed card when the user edits the task
A quiet window is the floor, not a hard mute: editing a task is a signal it's live again, so it should resurface on the deck before the cooldown elapses. Capture the task's modifiedTime at dismissal (after the agent's own approve-write, so the agent can't re-arm itself) as a baseline in the dismissed store { id: { at, mtime } }. buildBatch re-arms any dismissed card whose task's current modifiedTime exceeds its baseline — dropping it from both the covered set and the fresh() guard so the task re-enters scope. Legacy entries carry mtime:null and fall back to time-only expiry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4d1bf24 commit 3de6184

2 files changed

Lines changed: 38 additions & 12 deletions

File tree

examples/operator-deck/cadence.mjs

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,23 +39,25 @@ const COOLDOWN_MS = Number(process.env.OPERATOR_COOLDOWN_HOURS || 36) * 3600 * 1
3939
// Returns { map: {id: dismissedAt}, migrated } — migrates the legacy array
4040
// (permanent set) into a timestamped map so old entries get a fresh window once.
4141
function loadDismissedRaw(now = Date.now()) {
42+
const norm = (v) => (typeof v === 'number' ? { at: v, mtime: null } : { at: v?.at || 0, mtime: v?.mtime ?? null });
4243
try {
4344
const j = JSON.parse(fs.readFileSync(DISMISS_FILE, 'utf-8'));
44-
if (Array.isArray(j)) { const map = {}; for (const id of j) map[id] = now; return { map, migrated: true }; }
45-
return { map: (j && typeof j === 'object') ? j : {}, migrated: false };
45+
if (Array.isArray(j)) { const map = {}; for (const id of j) map[id] = { at: now, mtime: null }; return { map, migrated: true }; }
46+
if (j && typeof j === 'object') { const map = {}; for (const [id, v] of Object.entries(j)) map[id] = norm(v); return { map, migrated: false }; }
47+
return { map: {}, migrated: false };
4648
} catch { return { map: {}, migrated: false }; }
4749
}
4850
export function saveDismissed(map) { fs.mkdirSync(path.dirname(DISMISS_FILE), { recursive: true }); fs.writeFileSync(DISMISS_FILE, JSON.stringify(map)); }
4951
// Ids still inside the quiet window. Expired entries (and the legacy array form)
5052
// are persisted away on read so timestamps don't keep getting reset.
5153
export function activeDismissed(now = Date.now()) {
5254
const { map, migrated } = loadDismissedRaw(now);
53-
const live = {}; const set = new Set();
54-
for (const [id, at] of Object.entries(map)) { if (now - at < COOLDOWN_MS) { live[id] = at; set.add(id); } }
55+
const live = {};
56+
for (const [id, info] of Object.entries(map)) if (now - info.at < COOLDOWN_MS) live[id] = info;
5557
if (migrated || Object.keys(live).length !== Object.keys(map).length) saveDismissed(live);
56-
return set;
58+
return live;
5759
}
58-
export function dismiss(id, now = Date.now()) { const { map } = loadDismissedRaw(now); map[id] = now; saveDismissed(map); }
60+
export function dismiss(id, now = Date.now(), mtime = null) { const { map } = loadDismissedRaw(now); map[id] = { at: now, mtime: (mtime == null ? null : Number(mtime)) }; saveDismissed(map); }
5961

6062
async function noteProjectSet(adapter) {
6163
const set = new Set();
@@ -95,7 +97,7 @@ function draftIntents(tasks) {
9597
});
9698
}
9799

98-
export async function buildBatch(adapter, { existingIds = new Set(), dismissed = activeDismissed(), limit = BATCH } = {}) {
100+
export async function buildBatch(adapter, { existingIds = new Set(), dismissed: dismissedMap = activeDismissed(), limit = BATCH } = {}) {
99101
const now = Date.now();
100102
const { corpus } = await loadCorpus(adapter, { cache: true });
101103
const notes = await noteProjectSet(adapter);
@@ -104,6 +106,18 @@ export async function buildBatch(adapter, { existingIds = new Set(), dismissed =
104106
const meta = new Map(active.map((t) => [t.id, safeMeta(t)]));
105107
const state = loadState();
106108

109+
// Re-arm: a dismissed card is only still suppressing if the user hasn't edited
110+
// the task since it was dismissed. modifiedTime now exceeding the baseline we
111+
// captured (after the agent's own write) means a human edit -> offer it again.
112+
const mtimeById = new Map(active.map((t) => [t.id, new Date(t.modifiedTime || 0).getTime()]));
113+
const primaryTaskId = (id) => { const p = String(id).split(':'); return p[0] === 'relate' ? p[1] : p.slice(1).join(':'); };
114+
const dismissed = new Set();
115+
for (const [id, info] of Object.entries(dismissedMap)) {
116+
const cur = mtimeById.get(primaryTaskId(id)) || 0;
117+
if (info.mtime != null && cur > info.mtime) continue; // user touched the task -> re-armed
118+
dismissed.add(id);
119+
}
120+
107121
// Candidate pool: active, non-note, titled, and NOT decayed out (benched).
108122
const candidates = active.filter((t) => !isNote(t) && (t.title || '').trim() && !benchReason(state, t, now));
109123
const byId = new Map(candidates.map((t) => [t.id, t]));

examples/operator-deck/server.mjs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,19 @@ async function getAdapter() {
4747
// permanent — it expires after the cooldown (see cadence COOLDOWN_MS), so the task
4848
// can be reprocessed after a quiet window of ~24-48h. `covered` keys off the active
4949
// set too, so the task stops being re-picked only during that window.
50-
function retire(id) { dismiss(id); saveQueue(loadQueue().filter((c) => c.id !== id)); }
50+
async function retire(id, source) {
51+
// Capture the task's modifiedTime NOW (after any approve write) as the re-arm
52+
// baseline: a later user edit pushes modifiedTime past this and re-offers the card.
53+
let mtime = null;
54+
try {
55+
if (source?.projectId && source?.taskId) {
56+
const t = await (await getAdapter()).getTask(source.projectId, source.taskId);
57+
if (t?.modifiedTime) mtime = new Date(t.modifiedTime).getTime();
58+
}
59+
} catch { /* mtime is optional — fall back to time-only cooldown */ }
60+
dismiss(id, Date.now(), mtime);
61+
saveQueue(loadQueue().filter((c) => c.id !== id));
62+
}
5163

5264
const taskOf = (card) => card?.exec?.source?.taskId || card.id;
5365

@@ -121,23 +133,23 @@ const server = http.createServer(async (req, res) => {
121133
if (action === 'modify') {
122134
let note = '';
123135
try { note = String(JSON.parse((await readBody(req)) || '{}').note || '').slice(0, 1000); } catch { /* none */ }
124-
if (!DEMO) { retire(id); recordModify(id, note); }
136+
if (!DEMO) { await retire(id, card?.exec?.source); recordModify(id, note); }
125137
return send(res, 200, { ok: true, modified: id, note }, JSONH);
126138
}
127139

128140
if (action === 'reject') {
129141
if (!DEMO) {
130142
const st = loadState(); if (card) recordSkip(st, taskOf(card)); saveState(st);
131-
retire(id);
143+
await retire(id, card?.exec?.source);
132144
}
133145
return send(res, 200, { ok: true, dismissed: id }, JSONH);
134146
}
135147

136148
// approve
137149
if (!card) return send(res, 410, { error: 'suggestion no longer available' }, JSONH);
138-
if (DRYRUN) { if (!DEMO) retire(id); return send(res, 200, { ok: true, dryRun: true, summary: `Would ${card.kind}` }, JSONH); }
150+
if (DRYRUN) { if (!DEMO) await retire(id, card?.exec?.source); return send(res, 200, { ok: true, dryRun: true, summary: `Would ${card.kind}` }, JSONH); }
139151
const result = await executeSuggestion(await getAdapter(), card);
140-
retire(id);
152+
await retire(id, card?.exec?.source);
141153
return send(res, 200, { ok: true, ...result }, JSONH);
142154
}
143155

0 commit comments

Comments
 (0)