Compare commits

..

2 Commits

Author SHA1 Message Date
Variet Worker
9523d1328e fix(ext): workspaceUri 누락 + WS-only 전송 + user msg dedup 2026-03-17 10:38:45 +09:00
Variet Worker
96e9b8adce fix(bot): Hub WS auto-approve Discord 알림 누락 + !auto 이중발송 dedup 2026-03-17 10:37:55 +09:00
3 changed files with 66 additions and 13 deletions

26
bot.py
View File

@@ -189,6 +189,7 @@ class GravityBot(commands.Bot):
self.auto_approve_projects: set[str] = set() # projects with auto-approve enabled
self._processed_message_ids: deque[int] = deque(maxlen=200) # dedup for Gateway event replay
self._approval_messages: dict[str, int] = {} # FIX #4: request_id → discord message_id (for auto_resolved lookup)
self._last_auto_toggle: dict[str, float] = {} # project → timestamp (dedup for !auto embed)
self.gateway = None # Set by main.py in gateway mode
self.hub = None # Set by main.py in gateway mode (WSHub instance)
@@ -921,6 +922,14 @@ class GravityBot(commands.Bot):
# Special command: !auto — toggle auto-approve
if actual_text == "!auto":
# Dedup: skip if toggled within 5s for same project (Gateway event replay)
now = time.time()
last = self._last_auto_toggle.get(project, 0)
if now - last < 5.0:
logger.info(f"[AUTO] Dedup: skipping duplicate !auto for {project} ({now-last:.1f}s ago)")
return
self._last_auto_toggle[project] = now
# Toggle per-project auto-approve
if project in self.auto_approve_projects:
self.auto_approve_projects.discard(project)
@@ -1043,6 +1052,8 @@ class GravityBot(commands.Bot):
async def _auto_approve_via_hub(self, request: ApprovalRequest):
"""Auto-approve a pending request via Hub."""
self._sent_approval_ids.add(request.request_id)
if self.hub:
await self.hub.send_response_to_pending_owner(request.request_id, {
"type": "response",
@@ -1060,7 +1071,20 @@ class GravityBot(commands.Bot):
step_type=request.step_type,
project_name=request.project_name,
))
logger.info(f"[HUB-AUTO] Auto-approved: {request.request_id[:12]}")
# Send compact auto-approved embed to Discord (was missing — caused silent approvals)
channel = await self._get_channel(request.project_name)
if channel:
try:
embed = discord.Embed(
title="🤖 자동 승인됨",
description=f"```\n{request.command[:500]}\n```",
color=discord.Color.green(),
)
embed.set_footer(text=f"auto-approve | {request.request_id[:12]}")
await channel.send(embed=embed)
except Exception as e:
logger.error(f"[HUB-AUTO] Discord send failed: {e}")
logger.info(f"[HUB-AUTO] Auto-approved: {request.request_id[:12]} project={request.project_name}")
async def _hub_on_chat(self, project: str, data: dict):
"""Handle chat snapshot from Hub (Extension->Hub->Bot->Discord)."""

View File

@@ -103,7 +103,7 @@ const recentDiscordSentTexts: Map<string, number> = new Map();
function writeChatSnapshot(text: string) {
try {
// WS route (preferred)
// WS route (preferred) — skip file write to prevent duplicate Discord delivery
if (wsBridge && wsBridge.isConnected()) {
wsBridge.sendChat({
content: text,
@@ -111,8 +111,10 @@ function writeChatSnapshot(text: string) {
project_name: projectName,
});
logToFile(`[SNAPSHOT-WS] sent (${text.length} chars)`);
if (activeSessionId) { writeRegistration(activeSessionId); }
return;
}
// File route (fallback / Phase 0 dual-write)
// File route (fallback — only when WS is NOT connected)
const snapshotDir = path.join(bridgePath, 'chat_snapshots');
if (!fs.existsSync(snapshotDir)) { fs.mkdirSync(snapshotDir, { recursive: true }); }
const id = Date.now().toString();
@@ -136,7 +138,7 @@ function writeChatSnapshot(text: string) {
function writeChatSnapshotWithFiles(text: string, files: Array<{name: string, content: string}>) {
try {
// WS route (preferred)
// WS route (preferred) — skip file write to prevent duplicate Discord delivery
if (wsBridge && wsBridge.isConnected()) {
wsBridge.sendChat({
content: text,
@@ -145,8 +147,10 @@ function writeChatSnapshotWithFiles(text: string, files: Array<{name: string, co
project_name: projectName,
});
logToFile(`[SNAPSHOT-WS] sent with ${files.length} files (${text.length} chars)`);
if (activeSessionId) { writeRegistration(activeSessionId); }
return;
}
// File route (fallback)
// File route (fallback — only when WS is NOT connected)
const snapshotDir = path.join(bridgePath, 'chat_snapshots');
if (!fs.existsSync(snapshotDir)) { fs.mkdirSync(snapshotDir, { recursive: true }); }
const id = Date.now().toString();
@@ -1141,6 +1145,8 @@ export async function activate(context: vscode.ExtensionContext) {
lastPendingStepIndex,
stallProbed,
sawRunningAfterPending,
clickTrigger,
logToFile,
workspaceUri,
diffReviewMetadata: new Map(),
recentDiscordSentTexts,

View File

@@ -684,12 +684,21 @@ function setupMonitor() {
ctx.recentDiscordSentTexts.delete(trimmed);
ctx.logToFile(`[USER-MSG] skipped echo relay (Discord origin, ${Math.round((Date.now()-sentAt)/1000)}s ago)`);
} else if (umText.length > 2) {
const truncated = umText.length > 800
? umText.substring(0, 800) + '\n\n_(이하 생략)_'
: umText;
const source = isFromIDE ? 'AG 직접 입력' : 'API';
ctx.writeChatSnapshot(`👤 **사용자 (${source})**\n\n${truncated}`);
ctx.logToFile(`[USER-MSG] relayed ${umText.length} chars from step ${userInputIdx}`);
// Content-based dedup: AG can create multiple USER_INPUT steps for the same message
// (e.g. comment-while-working feature). Skip if same text relayed within 30s.
const dedupKey = `user_msg:${trimmed}`;
const lastRelayed = lastSnapshotText.get(dedupKey);
if (lastRelayed && (Date.now() - Number(lastRelayed)) < 30_000) {
ctx.logToFile(`[USER-MSG] skipped duplicate relay (same text ${Math.round((Date.now() - Number(lastRelayed))/1000)}s ago)`);
} else {
lastSnapshotText.set(dedupKey, String(Date.now()));
const truncated = umText.length > 800
? umText.substring(0, 800) + '\n\n_(이하 생략)_'
: umText;
const source = isFromIDE ? 'AG 직접 입력' : 'API';
ctx.writeChatSnapshot(`👤 **사용자 (${source})**\n\n${truncated}`);
ctx.logToFile(`[USER-MSG] relayed ${umText.length} chars from step ${userInputIdx}`);
}
} else {
ctx.writeChatSnapshot(`👤 **사용자** — _(내용 없음)_`);
ctx.logToFile(`[USER-MSG] step ${userInputIdx} text empty`);
@@ -1220,7 +1229,7 @@ export function writePendingApproval(data: { conversation_id: string; command: s
...(data.modified_files ? { modified_files: data.modified_files } : {}),
...(data.edit_step_indices && data.edit_step_indices.length > 0 ? { edit_step_indices: data.edit_step_indices } : {}),
};
// WS route (preferred) — send pending to Hub before file write
// WS route (preferred) — skip file write to prevent duplicate Discord delivery
if (ctx.wsBridge && ctx.wsBridge.isConnected()) {
ctx.wsBridge.sendPending({
request_id: id,
@@ -1234,8 +1243,22 @@ export function writePendingApproval(data: { conversation_id: string; command: s
edit_step_indices: data.edit_step_indices,
});
ctx.logToFile(`[PENDING-WS] sent pending ${id} cmd="${data.command.substring(0, 60)}"`);
// Cache diff_review metadata in-memory (needed for RPC acknowledgement)
if (data.step_type === 'diff_review' && (data.edit_step_indices?.length || data.modified_files?.length)) {
ctx.diffReviewMetadata.set(id, {
edit_step_indices: data.edit_step_indices || [],
modified_files: data.modified_files || [],
});
ctx.logToFile(`[DIFF-REVIEW-CACHE] stored metadata for rid=${id}`);
}
// Record in memory dedup
if (data.step_index !== undefined && data.conversation_id) {
recentPendingSteps.set(`${data.conversation_id}:${data.step_index}`, nowMs);
}
if (data.conversation_id) { writeRegistration(data.conversation_id); }
return;
}
// File route (fallback / Phase 0 dual-write)
// File route (fallback — only when WS is NOT connected)
fs.writeFileSync(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
console.log(`Gravity Bridge: pending approval written → ${id}.json`);
// Cache diff_review metadata in-memory (survives pending file deletion by Collector/Bot)