feat: lock file deletion = instant done signal

When the gateway deletes .jsonl.lock it means the final reply was sent.
Use this as an immediate 'turn complete' trigger instead of waiting for
cache-ttl (3s grace) or idle timeout (60s).

- status-watcher.js: _onFileChange checks if .lock file exists on event.
  If deleted -> emits 'session-lock-released'. Added triggerIdle() which
  cancels all timers and emits session-idle immediately.
- watcher-manager.js: wires session-lock-released/session-lock-released-path
  to watcher.triggerIdle() for instant completion.

Combined with lock-created trigger (previous commit), the full lifecycle is:
  User sends message
    -> .jsonl.lock created -> status box appears immediately
    -> JSONL writes -> status box updates in real time
    -> Gateway sends reply -> .jsonl.lock deleted -> status box marks done instantly
This commit is contained in:
sol
2026-03-09 18:49:26 +00:00
parent 0bdfaaa01d
commit cdef7a1903
2 changed files with 54 additions and 11 deletions

View File

@@ -225,19 +225,31 @@ class StatusWatcher extends EventEmitter {
// Emit 'session-lock' so watcher-manager can trigger immediate reactivation // Emit 'session-lock' so watcher-manager can trigger immediate reactivation
// without waiting for the first JSONL write. // without waiting for the first JSONL write.
if (fullPath.endsWith('.jsonl.lock')) { if (fullPath.endsWith('.jsonl.lock')) {
// Derive the JSONL path and find the session key
const jsonlPath = fullPath.slice(0, -5); // strip '.lock' const jsonlPath = fullPath.slice(0, -5); // strip '.lock'
const sessionKey = this.fileToSession.get(jsonlPath) || const lockExists = (() => { try { fs.statSync(fullPath); return true; } catch(_) { return false; } })();
this.fileToSession.get('\x00ghost:' + jsonlPath.replace('\x00ghost:', ''));
if (sessionKey) { // Resolve session key from known sessions or ghost watch
const originalKey = sessionKey.startsWith('\x00ghost:') ? sessionKey.slice(7) : sessionKey; let sessionKey = this.fileToSession.get(jsonlPath);
if (this.logger) { if (sessionKey && sessionKey.startsWith('\x00ghost:')) sessionKey = sessionKey.slice(7);
this.logger.info({ sessionKey: originalKey }, 'Lock file detected — session started, triggering early reactivation');
if (lockExists) {
// Lock file CREATED — gateway started processing user message.
// Earliest possible signal: fires before any JSONL write.
if (sessionKey) {
if (this.logger) this.logger.info({ sessionKey }, 'Lock file created — session active, triggering early reactivation');
this.emit('session-lock', sessionKey);
} else {
this.emit('session-lock-path', jsonlPath);
} }
this.emit('session-lock', originalKey);
} else { } else {
// Unknown session — emit with path so watcher-manager can look it up // Lock file DELETED — gateway finished the turn and sent final reply.
this.emit('session-lock-path', jsonlPath); // Immediate idle signal: no need to wait for cache-ttl or 60s timeout.
if (sessionKey) {
if (this.logger) this.logger.info({ sessionKey }, 'Lock file deleted — turn complete, marking session done immediately');
this.emit('session-lock-released', sessionKey);
} else {
this.emit('session-lock-released-path', jsonlPath);
}
} }
return; return;
} }
@@ -490,6 +502,22 @@ class StatusWatcher extends EventEmitter {
* Schedule an idle check for a session. * Schedule an idle check for a session.
* @private * @private
*/ */
/**
* Immediately mark a session as done without waiting for idle timeout.
* Called when the lock file is deleted — the gateway has sent the final reply.
* @param {string} sessionKey
*/
triggerIdle(sessionKey) {
const state = this.sessions.get(sessionKey);
if (!state) return;
// Cancel any pending timers
if (state.idleTimer) { clearTimeout(state.idleTimer); state.idleTimer = null; }
if (state._filePollTimer) { clearInterval(state._filePollTimer); state._filePollTimer = null; }
if (this.logger) this.logger.info({ sessionKey }, 'triggerIdle: lock released — marking session done immediately');
state.status = 'done';
this.emit('session-idle', sessionKey, this._sanitizeState(state));
}
_scheduleIdleCheck(sessionKey, state) { _scheduleIdleCheck(sessionKey, state) {
if (state.idleTimer) { if (state.idleTimer) {
clearTimeout(state.idleTimer); clearTimeout(state.idleTimer);

View File

@@ -387,7 +387,6 @@ async function startDaemon() {
// Lock file for a session the watcher doesn't know about yet — look it up by path // Lock file for a session the watcher doesn't know about yet — look it up by path
watcher.on('session-lock-path', (jsonlPath) => { watcher.on('session-lock-path', (jsonlPath) => {
// Find the session key by matching sessionFile in sessions.json
const sessionKey = monitor.findSessionByFile(jsonlPath); const sessionKey = monitor.findSessionByFile(jsonlPath);
if (sessionKey) { if (sessionKey) {
logger.info({ sessionKey }, 'Lock file (path lookup): session activated — early reactivation'); logger.info({ sessionKey }, 'Lock file (path lookup): session activated — early reactivation');
@@ -396,6 +395,22 @@ async function startDaemon() {
} }
}); });
// ---- Lock file released: turn complete, mark done immediately ----
// Gateway deletes .jsonl.lock when the final reply is sent to the user.
// This is the most reliable "done" signal — no waiting for cache-ttl or 60s idle.
watcher.on('session-lock-released', (sessionKey) => {
logger.info({ sessionKey }, 'Lock file released — turn complete, triggering immediate idle');
watcher.triggerIdle(sessionKey);
});
watcher.on('session-lock-released-path', (jsonlPath) => {
const sessionKey = monitor.findSessionByFile(jsonlPath);
if (sessionKey) {
logger.info({ sessionKey }, 'Lock file released (path lookup) — triggering immediate idle');
watcher.triggerIdle(sessionKey);
}
});
// ---- Session Update (from watcher) ---- // ---- Session Update (from watcher) ----
watcher.on('session-update', (sessionKey, state) => { watcher.on('session-update', (sessionKey, state) => {
const box = activeBoxes.get(sessionKey); const box = activeBoxes.get(sessionKey);