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:
@@ -225,19 +225,31 @@ class StatusWatcher extends EventEmitter {
|
||||
// Emit 'session-lock' so watcher-manager can trigger immediate reactivation
|
||||
// without waiting for the first JSONL write.
|
||||
if (fullPath.endsWith('.jsonl.lock')) {
|
||||
// Derive the JSONL path and find the session key
|
||||
const jsonlPath = fullPath.slice(0, -5); // strip '.lock'
|
||||
const sessionKey = this.fileToSession.get(jsonlPath) ||
|
||||
this.fileToSession.get('\x00ghost:' + jsonlPath.replace('\x00ghost:', ''));
|
||||
if (sessionKey) {
|
||||
const originalKey = sessionKey.startsWith('\x00ghost:') ? sessionKey.slice(7) : sessionKey;
|
||||
if (this.logger) {
|
||||
this.logger.info({ sessionKey: originalKey }, 'Lock file detected — session started, triggering early reactivation');
|
||||
const lockExists = (() => { try { fs.statSync(fullPath); return true; } catch(_) { return false; } })();
|
||||
|
||||
// Resolve session key from known sessions or ghost watch
|
||||
let sessionKey = this.fileToSession.get(jsonlPath);
|
||||
if (sessionKey && sessionKey.startsWith('\x00ghost:')) sessionKey = sessionKey.slice(7);
|
||||
|
||||
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 {
|
||||
// Unknown session — emit with path so watcher-manager can look it up
|
||||
this.emit('session-lock-path', jsonlPath);
|
||||
// Lock file DELETED — gateway finished the turn and sent final reply.
|
||||
// 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;
|
||||
}
|
||||
@@ -490,6 +502,22 @@ class StatusWatcher extends EventEmitter {
|
||||
* Schedule an idle check for a session.
|
||||
* @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) {
|
||||
if (state.idleTimer) {
|
||||
clearTimeout(state.idleTimer);
|
||||
|
||||
@@ -387,7 +387,6 @@ async function startDaemon() {
|
||||
|
||||
// Lock file for a session the watcher doesn't know about yet — look it up by path
|
||||
watcher.on('session-lock-path', (jsonlPath) => {
|
||||
// Find the session key by matching sessionFile in sessions.json
|
||||
const sessionKey = monitor.findSessionByFile(jsonlPath);
|
||||
if (sessionKey) {
|
||||
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) ----
|
||||
watcher.on('session-update', (sessionKey, state) => {
|
||||
const box = activeBoxes.get(sessionKey);
|
||||
|
||||
Reference in New Issue
Block a user