Agent system prompts rot the same way every time: each fix tacks on a sentence, nothing ever comes off, and the prompt grows forever. Two things break as a result — the prompt drifts past whatever size budget you have, and (the subtle one) the prompt's prose is the agent's prose. A verbose, redundant constitution teaches a verbose, redundant writing voice.
So make every addition carry a matching subtraction. When you add a rule, find the existing rule it makes redundant and cut it. In practice the new rule almost always overlaps something — a preamble about "only ask when a decision genuinely needs the human" makes a stale "no confirmation requests for obvious next steps" bullet redundant; fold one into the other.
The part people skip: don't eyeball whether the wording "fits" — measure it. Render the prompt with all includes expanded, byte-count it, and compare against a budget:
# render each role's full prompt and check it against a per-role ceiling
for src in agents/*.md; do
role=$(basename "$src" .md)
_mat_render_prompt "$src" "/tmp/$role.md"
size=$(wc -c < "/tmp/$role.md")
ceiling=$(jq -r --arg r "$role" '.[$r]' ceilings.json)
echo "$role: $size / $ceiling (headroom $((ceiling-size)))"
done
A checked-in per-role ceiling turns this into a ratchet: adding a line either fits under existing headroom, or forces you to bump the ceiling in the same diff — which makes the growth visible and reviewable instead of silent.
Measuring mattered more than expected. The fattest candidate wording netted +161 bytes; the tightest role had exactly 163 bytes of headroom. Two bytes of slack — and a longer {{USERNAME}} at render time would have blown it. Invisible by eye, obvious once counted. We trimmed the wording to net +135 and left every ceiling untouched.
And run the ratchet both ways. When you remove fat, lower the ceiling to the new size × 1.10 in the same change — otherwise the slack you just created quietly refills.
In development, your frontend runs on localhost:5173 and your API server on localhost:3000. The browser blocks cross-origin requests — that's CORS. Vite's dev proxy solves this by forwarding /api/* requests to the backend, making them look same-origin to the browser:
// vite.config.ts
server: {
proxy: {
'/api': 'http://localhost:3000'
}
}
In production this proxy disappears. The built frontend is just static files (HTML/JS/CSS) — no port, no process. Nginx or a CDN serves them, and reverse-proxies /api/* to the backend the same way Vite did in dev:
user → Nginx :80
├── /api/* → backend :3000
└── /* → dist/ static files
One port from the user's perspective, no CORS issue. The backend port is always real and needed; the frontend "port" only exists during development because Vite's dev server is a live process.
在软件工程中,我们最害怕的不是那些会产生堆栈跟踪、让系统崩溃的“喧闹” Bug,而是那些“沉默”的刺客。它们悄无声息地让你的系统功能失灵,却不留下一丝痕迹——没有错误日志,没有CPU飙升,甚至健康检查也一路绿灯。
最近,我们就遇到了这样一个“完美罪犯”。
案发现场
我们有一个基于 .NET Core 的后台服务,它作为 IHostedService 运行,负责从 AWS SQS 队列中持续拉取消息并进行处理。在一次常规的依赖库升级后,这个服务表现出了诡异的行为:
服务启动后,它能成功处理第一批消息。然后,就“死”了。
它不再从队列中拉取任何新消息,但容器依然在运行,健康检查接口返回 200 OK。最令人困惑的是,日志面板一片寂静,没有任何异常或警告。服务就像一个进入了深度睡眠的活死人。
迷雾重重的调查
面对这种“静默假死”,我们团队立刻召集了“案情分析会”,并列出了一系列合理的“嫌疑人”:
- API 限流 (Throttling):我们刚刚重构了
QueueService,移除了队列 URL 的缓存。是不是因为每次轮询都去调用 GetQueueUrl,导致被 AWS API 限流了?
- 网络阻塞/死锁:新的 AWS SDK 行为可能有所不同。是不是因为长轮询在网络抖动时被永久挂起,而我们又没有传递
CancellationToken 导致无法取消?
- 高频失败循环 (Tight Error Loop):是不是某个地方持续抛出异常,
catch 块虽然捕获了它,但没有设计退避策略,导致后台线程在高速空转,把日志系统拖垮了?
这些都是非常合理的推断,每一个都可能导致我们看到的现象。我们花了大量时间去审查代码、分析理论,甚至准备好了复杂的修复方案,比如重新实现带 SemaphoreSlim 的并发缓存、添加指数退避逻辑等。
然而,我们所有的推断都错了。
真相大白:一个null引发的血案
真正的罪魁祸首,隐藏在一个我们意想不到的地方,其貌不扬,甚至有些可笑。它不是复杂的云服务交互问题,而是一个基础的 C# 空引用异常。
在我们的 QueueProcessorService 中,有这样一段逻辑:
// _StartQueueProcessingAsync() in QueueProcessorService
List<Message> messageList = new List<Message>();
try
{
// 调用重构后的 QueueService
messageList = await _queueService.ReceiveMessageAsync(request);
}
catch (Exception e)
{
_logger.Error(e, e.Message);
}
finally
{
// 如果 messageList 里有消息,就去处理
if (messageList.Any()) // <-- 致命的一行
{
var processingTasks = messageList.Select(ProcessMessageAsync).ToArray();
await Task.WhenAll(processingTasks);
}
else
{
await Task.Delay(500);
}
}
问题出在哪里?
在我们升级 AWSSDK.SQS 库之后,_amazonSqs.ReceiveMessageAsync() 的行为发生了一个微小但致命的破坏性变更:当队列为空时,返回的 ReceiveMessageResponse 对象中的 Messages 属性不再是一个空列表 [],而是 null。
我们的 QueueService 在修复前,直接将这个 null 返回给了调用者。于是,在 QueueProcessorService 中,messageList 变量在队列为空时被赋值为 null。
接下来,程序进入 finally 块,执行 messageList.Any()。在一个 null 对象上调用任何实例方法,结果只有一个:NullReferenceException。
帮凶:被“遗忘”的后台任务
一个 NullReferenceException 足以致命,但为什么它能做到悄无声息?这就引出了本案的“帮凶”——我们启动后台任务的方式。
在 IHostedService 的 StartAsync 方法中,我们这样启动了主循环:
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.Info("Service is running...");
// “即发即忘”式启动
_queueProcessingTask = _StartQueueProcessingAsync();
return Task.CompletedTask;
}
这种“即发即忘”(Fire-and-Forget)的模式有一个巨大的隐患:如果 _queueProcessingTask 在未来的某个时刻因为一个未处理的异常而失败(Faulted),这个异常不会被传播,它会被静默地“吞噬”掉。
我们的 NullReferenceException 正好发生在一个没有任何 try-catch 保护的 finally 块中,它成为了一个未处理异常,直接杀死了 _StartQueueProcessingAsync 任务。而我们程序的其他部分对此一无所知,继续假装一切正常。
我们学到的教训
这次艰难的排错过程给我们留下了几个深刻的教训:
-
警惕第三方库的“微小”变更:永远不要想当然地认为依赖库的次要版本升级是完全无害的。null 和 [] 的区别,足以让一个健壮的系统瞬间瘫痪。仔细阅读更新日志(Changelog)至关重要。
-
奉行防御性编程:永远不要完全信任方法的返回值。对于任何可能返回集合的方法,都应该做好它返回 null 的准备。一个简单的 ?? [] 就能拯救世界。
// 修复方案
var response = await _amazonSqs.ReceiveMessageAsync(request, cancellationToken);
return response.Messages ?? []; // 永远返回一个有效的列表
-
永远不要“遗忘”你的后台任务:对于“即发即忘”的后台任务,必须建立一个“观察哨”。最简单的方式是在启动它的地方包裹一个 try-catch,确保任何致命异常都能被记录下来。
// 更健壮的启动方式
public Task StartAsync(CancellationToken cancellationToken)
{
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_queueProcessingTask = Task.Run(async () =>
{
try
{
await _StartQueueProcessingAsync(_cancellationTokenSource.Token);
}
catch (Exception ex)
{
// 记录致命错误,这会让问题立刻暴露
_logger.Fatal(ex, "The queue processing task has crashed unexpectedly.");
}
}, _cancellationTokenSource.Token);
return Task.CompletedTask;
}
这次经历提醒我们,最危险的 Bug 往往不是那些复杂的算法或架构问题,而是由一连串微小的疏忽和意外共同造成的。保持敬畏,编写健壮、可预测的代码,才是我们对抗这些“沉默刺客”的最好武器。
Problem
Mouse left swipe to delete notes was not working on desktop platforms (Windows, macOS, Linux, Web browsers).
Root Cause Analysis
- Flutter Dismissible Limitations:
Dismissible widget is optimized for touch interactions, not mouse gestures
- SelectionArea Gesture Conflict:
SelectionArea intercepted mouse drag events for text selection, blocking Dismissible swipe gestures
Solution
Initial Approach (Failed)
Attempted platform-specific custom GestureDetector implementation:
- Added desktop platform detection logic
- Implemented custom mouse drag threshold calculations
- Issue:
GestureDetector only detects gestures but provides no visual feedback
Final Solution (Working)
Two-part fix for Dismissible widget:
- Immediate Mouse Response
Dismissible(
dragStartBehavior: DragStartBehavior.down, // Key fix
// ... other properties
)
- Gesture Conflict Resolution
// Before: SelectionArea wrapping entire content
SelectionArea(
child: GestureDetector(...) // Blocked swipe gestures
)
// After: SelectionArea only around text content
GestureDetector(
child: Column([
metadata,
SelectionArea(child: noteContent), // Limited scope
footer,
])
)
Technical Details
dragStartBehavior Impact
DragStartBehavior.start (default): Waits for drag distance threshold
DragStartBehavior.down: Starts drag immediately on mouse down
- Desktop users expect immediate response to mouse actions
SelectionArea Scope Reduction
- Problem: Full-content
SelectionArea captured all mouse events
- Solution: Limit
SelectionArea to text content only
- Result: Swipe gestures work on margins, text selection works on content
Testing Strategy
Created comprehensive test suite covering:
Dismissible configuration validation
- Platform-specific behavior
- Gesture conflict scenarios
- Integration with existing callbacks
Key Learnings
- Flutter's
Dismissible supports desktop with proper configuration
- Widget event hierarchies can cause unexpected gesture conflicts
- Scope reduction often beats complex custom implementations
- Platform-specific UX requires careful gesture management
Code Impact
- Files Modified:
note_list_item.dart
- Tests Added:
note_list_item_test.dart
- Lines Changed:
30 additions, 15 deletions
- Breaking Changes: None
Verification
- All tests pass
- Code analysis clean
- Desktop mouse swipe delete functional
- Text selection preserved
Problem
Flutter web apps on iOS Safari exhibit a "double selection" bug where text selection creates two overlapping selection layers, causing visual artifacts and
interaction issues.
Root Cause
iOS Safari creates both native browser text selection AND Flutter's custom SelectionArea selection simultaneously, resulting in conflicting selection states.
Working Workaround
HTML Solution (web/index.html)
<head>
<style>
* {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
/* Disable caret to prevent selection artifacts */
caret-color: rgba(255, 255, 255, 0) !important;
}
</style>
</head>
<body oncontextmenu="event.preventDefault();" >
...
Flutter Integration
// In main.dart or app initialization
import 'package:flutter/gestures.dart';
void main() {
// Disable browser context menu to let Flutter handle selection
if (kIsWeb) {
BrowserContextMenu.disableContextMenu();
}
runApp(MyApp());
}
How It Works
- Disables native selection - user-select: none prevents Safari's text selection
- Blocks touch events - Prevents iOS touch selection gestures
- Maintains Flutter selection - BrowserContextMenu.disableContextMenu() allows Flutter's SelectionArea to work
- Hides caret artifacts - caret-color: transparent eliminates visual glitches
Trade-offs
- ❌ Breaks native web text selection outside Flutter widgets
- ❌ May affect accessibility tools
- ✅ Provides consistent cross-platform selection behavior
- ✅ Eliminates iOS-specific double selection bug
Status
This workaround is recommended for Flutter web apps requiring text selection on iOS Safari until the official framework fix is released.