spexcode 0.5.9 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/spec-cli/src/cli.ts +1 -23
- package/spec-cli/src/delivery-queue.ts +107 -0
- package/spec-cli/src/harness.ts +1 -1
- package/spec-cli/src/help.ts +1 -1
- package/spec-cli/src/index.ts +2 -1
- package/spec-cli/src/session-cursors.ts +11 -17
- package/spec-cli/src/session-follow.ts +6 -6
- package/spec-cli/src/sessions.ts +58 -29
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +5 -41
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spexcode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
|
|
6
6
|
"license": "MIT",
|
package/spec-cli/src/cli.ts
CHANGED
|
@@ -660,7 +660,7 @@ if (cmd === 'serve') {
|
|
|
660
660
|
} else if (sub === 'wait') {
|
|
661
661
|
const selectors = positionals(4)
|
|
662
662
|
const kit = await followKit(selectors, 'spex session wait')
|
|
663
|
-
const named = selectors.join(' ') || 'your
|
|
663
|
+
const named = selectors.join(' ') || 'your own log'
|
|
664
664
|
// point-of-use turn-freeze warning ([[session-follow]]): a managed agent that runs this wait in the FOREGROUND
|
|
665
665
|
// freezes its whole turn until the target produces an edge — a warning that used to live only in help
|
|
666
666
|
// prose, now said where it matters. Foreground vs background is invisible from here, so the hint prints
|
|
@@ -1043,28 +1043,6 @@ if (cmd === 'serve') {
|
|
|
1043
1043
|
const st = process.argv[4] as any
|
|
1044
1044
|
const ok = mark(() => s.markState(st, { proposal: flag('propose') as any, note: flag('note'), sessionId: sess }))
|
|
1045
1045
|
console.log(ok.ok ? `state -> ${st}${noteEcho(flag('note'))}` : ok.reason ?? noRecord())
|
|
1046
|
-
} else if (sub === 'session-cursor') {
|
|
1047
|
-
// the turn-boundary mail reader advances its own inbox cursor here ([[session-cursors]]) — the same
|
|
1048
|
-
// one-writer discipline as session-state: shell reads the file, it never rewrites it, so a follower's
|
|
1049
|
-
// entries in the same file cannot be clobbered by a partial shell write.
|
|
1050
|
-
const to = Number(flag('to'))
|
|
1051
|
-
const sess = flag('session')
|
|
1052
|
-
if (process.argv[4] !== 'inbox' || !sess || !Number.isFinite(to)) {
|
|
1053
|
-
console.error('usage: spex internal session-cursor inbox --session <id> --to <event-index>')
|
|
1054
|
-
process.exit(2)
|
|
1055
|
-
}
|
|
1056
|
-
const { advanceInbox, inboxCursor } = await import('./session-cursors.js')
|
|
1057
|
-
const { readAliasedRawRecord } = await import('./layout.js')
|
|
1058
|
-
// the hook may address a codex THREAD id; the cursor file is keyed by the record id, so resolve the alias
|
|
1059
|
-
// through the one seam that owns that rule.
|
|
1060
|
-
const record = readAliasedRawRecord(sess)
|
|
1061
|
-
if (!record) console.log('noop (no session record)')
|
|
1062
|
-
else {
|
|
1063
|
-
advanceInbox(record.session_id, to)
|
|
1064
|
-
// report where the cursor ACTUALLY is: advancing is monotonic, so a lower offer is ignored, and
|
|
1065
|
-
// echoing the request back would confirm a move that did not happen.
|
|
1066
|
-
console.log(`inbox -> ${inboxCursor(record.session_id)}`)
|
|
1067
|
-
}
|
|
1068
1046
|
} else if (sub === 'session-fail') {
|
|
1069
1047
|
// StopFailure is one native source for the shared active-only turn-failure CAS. A declaration or explicit
|
|
1070
1048
|
// stop that landed first is authoritative, just as it is for Codex notifications and headless exits.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { runtimeRoot, sessionArtifactPath, sessionStoreDir } from './layout.js'
|
|
4
|
+
|
|
5
|
+
// @@@ delivery-queue - what a session still OWES its agent. The log ([[session-timeline]]) is the record and
|
|
6
|
+
// grows forever; this is the debt and is consumed, so it lives in its own small file whose resting state is
|
|
7
|
+
// EMPTY. Nothing here reads the log: an entry carries the text it will hand over, so history could be trimmed
|
|
8
|
+
// or archived without changing what is owed. A session that predates this mechanism owes nothing, because a
|
|
9
|
+
// queue is only ever filled by an enqueue — which is why no backlog migration exists.
|
|
10
|
+
|
|
11
|
+
export type PendingMessage = { mid: string; text: string; from: string | null }
|
|
12
|
+
|
|
13
|
+
const queuePath = (id: string): string => sessionArtifactPath(id, 'pending.json')
|
|
14
|
+
|
|
15
|
+
// @@@ its own lock, deliberately NOT the record lock - the drain holds this across the adapter insert, which
|
|
16
|
+
// is what makes "claim" real: two processes draining the same session cannot both hand over one message. The
|
|
17
|
+
// record lock could never span that call — a native turn runs lifecycle hooks that re-enter the record writer,
|
|
18
|
+
// and holding it there deadlocks the adapter's own confirmation. Nothing in the delivery path takes this one,
|
|
19
|
+
// so spanning the insert costs no contention. PID liveness reclaims a lock whose holder died mid-insert.
|
|
20
|
+
const lockRoot = (): string => join(runtimeRoot(), '.delivery-locks')
|
|
21
|
+
const lockPath = (id: string): string => join(lockRoot(), `${id}.lock`)
|
|
22
|
+
|
|
23
|
+
const pause = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
|
24
|
+
|
|
25
|
+
async function acquire(id: string, timeoutMs: number): Promise<(() => void) | null> {
|
|
26
|
+
mkdirSync(lockRoot(), { recursive: true })
|
|
27
|
+
const path = lockPath(id), deadline = Date.now() + timeoutMs
|
|
28
|
+
for (;;) {
|
|
29
|
+
try {
|
|
30
|
+
const fd = openSync(path, 'wx')
|
|
31
|
+
writeSync(fd, String(process.pid))
|
|
32
|
+
closeSync(fd)
|
|
33
|
+
return () => { try { unlinkSync(path) } catch { /* a liveness reclaim already removed it */ } }
|
|
34
|
+
} catch (e) {
|
|
35
|
+
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
|
|
36
|
+
let owner = 0
|
|
37
|
+
try { owner = Number(readFileSync(path, 'utf8').trim()) || 0 } catch { /* race with creator/releaser */ }
|
|
38
|
+
if (owner && owner !== process.pid) {
|
|
39
|
+
try { process.kill(owner, 0) } catch { try { unlinkSync(path) } catch { /* race */ }; continue }
|
|
40
|
+
}
|
|
41
|
+
// A drain is never urgent enough to fight for: whoever holds the lock is delivering these same messages,
|
|
42
|
+
// and the retry sweep will come back. Declining is not a lost message.
|
|
43
|
+
if (Date.now() >= deadline) return null
|
|
44
|
+
await pause(25)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function read(id: string): PendingMessage[] {
|
|
50
|
+
try {
|
|
51
|
+
const raw = JSON.parse(readFileSync(queuePath(id), 'utf8')) as unknown
|
|
52
|
+
if (!Array.isArray(raw)) return []
|
|
53
|
+
return raw.filter((m): m is PendingMessage =>
|
|
54
|
+
!!m && typeof m === 'object'
|
|
55
|
+
&& typeof (m as PendingMessage).mid === 'string'
|
|
56
|
+
&& typeof (m as PendingMessage).text === 'string')
|
|
57
|
+
} catch { return [] } // absent, empty, or unparseable all mean the honest thing: nothing owed
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Written whole and atomically; an empty queue is REMOVED rather than left as `[]`, so "is anything owed?" is
|
|
61
|
+
// one existsSync on the sweep's hot path.
|
|
62
|
+
function write(id: string, msgs: PendingMessage[]): void {
|
|
63
|
+
const path = queuePath(id)
|
|
64
|
+
if (!msgs.length) { try { unlinkSync(path) } catch { /* already gone */ } ; return }
|
|
65
|
+
mkdirSync(sessionStoreDir(id), { recursive: true })
|
|
66
|
+
const tmp = `${path}.${process.pid}.tmp`
|
|
67
|
+
writeFileSync(tmp, JSON.stringify(msgs, null, 2) + '\n')
|
|
68
|
+
renameSync(tmp, path)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The enqueue rides the timeline append ([[dispatch]]): the caller holds the session's RECORD lock across
|
|
72
|
+
// both, and the record is written first, so a crash between them leaves a message visible but undelivered —
|
|
73
|
+
// never delivered but unrecorded.
|
|
74
|
+
export function enqueue(id: string, msg: PendingMessage): void {
|
|
75
|
+
write(id, [...read(id), msg])
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const pendingMessages = (id: string): PendingMessage[] => read(id)
|
|
79
|
+
|
|
80
|
+
export const owesDelivery = (id: string): boolean => existsSync(queuePath(id))
|
|
81
|
+
|
|
82
|
+
// Hand over what is owed, in order, exactly once. `insert` reports whether the adapter took the message: only
|
|
83
|
+
// then is the entry dropped. A refusal ENDS the pass with that entry still queued and everything behind it
|
|
84
|
+
// still behind it — order is a property of a conversation, so a message is never skipped to deliver a later
|
|
85
|
+
// one. Returns how many were handed over and how many are still owed.
|
|
86
|
+
export async function drain(
|
|
87
|
+
id: string,
|
|
88
|
+
insert: (msg: PendingMessage) => Promise<boolean>,
|
|
89
|
+
timeoutMs = 5_000,
|
|
90
|
+
): Promise<{ delivered: number; remaining: number }> {
|
|
91
|
+
const release = await acquire(id, timeoutMs)
|
|
92
|
+
if (!release) return { delivered: 0, remaining: read(id).length }
|
|
93
|
+
let delivered = 0
|
|
94
|
+
try {
|
|
95
|
+
for (;;) {
|
|
96
|
+
const queued = read(id)
|
|
97
|
+
if (!queued.length) return { delivered, remaining: 0 }
|
|
98
|
+
let ok = false
|
|
99
|
+
try { ok = await insert(queued[0]) } catch { ok = false }
|
|
100
|
+
if (!ok) return { delivered, remaining: queued.length }
|
|
101
|
+
// Re-read before removing: a send that landed while this pass ran appended to the tail, and rewriting a
|
|
102
|
+
// stale snapshot minus the head would silently drop it.
|
|
103
|
+
write(id, read(id).filter((m) => m.mid !== queued[0].mid))
|
|
104
|
+
delivered++
|
|
105
|
+
}
|
|
106
|
+
} finally { release() }
|
|
107
|
+
}
|
package/spec-cli/src/harness.ts
CHANGED
|
@@ -282,7 +282,7 @@ export interface Harness {
|
|
|
282
282
|
// write one idempotent rendezvous reply; Codex uses JSON-RPC on the same app-server WebSocket the
|
|
283
283
|
// visible TUI uses — it reads the thread live and either `turn/steer`s the message INTO an in-progress turn
|
|
284
284
|
// (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
|
|
285
|
-
// `ok=false` leaves the
|
|
285
|
+
// `ok=false` leaves the message OWED on the session's delivery queue, for a later pass to hand over.
|
|
286
286
|
deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
|
|
287
287
|
// Observe native turn failures that this harness does not expose as a lifecycle hook. The adapter owns the
|
|
288
288
|
// transport subscription; sessions owns observer reconciliation and the active-only lifecycle CAS.
|
package/spec-cli/src/help.ts
CHANGED
|
@@ -47,7 +47,7 @@ session to that node. --prompt-file <path>|- carries a long prompt without shell
|
|
|
47
47
|
watch: ['spex session watch [SEL…] [--as NAME] [--idle] [--interval N=1]',
|
|
48
48
|
'Streams lifecycle transitions and blocks until killed; `session wait` is the one-shot alternative.', ['selector']],
|
|
49
49
|
wait: ['spex session wait [SEL…] [--timeout S=1200] [--interval S=1] [--idle]',
|
|
50
|
-
`EDGE-TRIGGERED wait: follows the selected sessions' logs AND your own
|
|
50
|
+
`EDGE-TRIGGERED wait: follows the selected sessions' logs AND your own log, and exits 0 on
|
|
51
51
|
the FIRST thing worth waking for — a followed session TRANSITIONING from a non-actionable
|
|
52
52
|
status into an actionable one (stdout = the observed path, e.g. working→review; read the LAST
|
|
53
53
|
token as the status reached), or a message arriving for you (stdout = message). The arrival
|
package/spec-cli/src/index.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { getBoardJson } from './graphCache.js'
|
|
|
18
18
|
import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
|
|
19
19
|
import { gitA, gitTry, repoRoot } from './git.js'
|
|
20
20
|
import { cockpitReview } from './cockpit.js'
|
|
21
|
-
import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, quarantineCorruptRecord, restoreQuarantinedRecord, archiveSession, resumeSession, mergeSession, captureSessionResult, sessionPrompt, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
|
|
21
|
+
import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, quarantineCorruptRecord, restoreQuarantinedRecord, archiveSession, resumeSession, mergeSession, captureSessionResult, sessionPrompt, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, superviseDelivery, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
|
|
22
22
|
import { readTimeline } from './session-timeline.js'
|
|
23
23
|
import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js'
|
|
24
24
|
import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
|
|
@@ -700,6 +700,7 @@ injectWebSocket(server)
|
|
|
700
700
|
superviseBridges() // restore visible helpers after failure; their viewer subscriptions survive replacement
|
|
701
701
|
superviseQueue() // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
|
|
702
702
|
superviseTurnFailures() // reconcile adapter-owned native failure subscriptions across backend replacement
|
|
703
|
+
superviseDelivery() // hand over messages an earlier pass could not ([[delivery-queue]]): the retry half of dispatch
|
|
703
704
|
console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`)
|
|
704
705
|
|
|
705
706
|
let graphWatchersClosed = false
|
|
@@ -4,10 +4,13 @@ import { sessionArtifactPath, sessionStoreDir } from './layout.js'
|
|
|
4
4
|
import type { TimelineEvent } from './session-timeline.js'
|
|
5
5
|
|
|
6
6
|
// @@@ session-cursors - a reader's durable place in a log. One `cursors.json` per session in its global store
|
|
7
|
-
// dir: `
|
|
8
|
-
// event INDEX into timeline.ndjson (lines already consumed), so it is also the index of
|
|
7
|
+
// dir: `follows` holds one entry per followed session, including the reader's own id when it watches its own
|
|
8
|
+
// log. A position is an event INDEX into timeline.ndjson (lines already consumed), so it is also the index of
|
|
9
|
+
// the next unread event. A position is NOT a work list: what a session still owes its agent is a debt, and it
|
|
10
|
+
// lives in its own queue ([[delivery-queue]]). Binding both to one counter is what made a session's own status
|
|
11
|
+
// lines get consumed as though they were mail, and made "anything outstanding?" a scan of all history.
|
|
9
12
|
|
|
10
|
-
export type Cursors = { version: 1;
|
|
13
|
+
export type Cursors = { version: 1; follows: Record<string, number> }
|
|
11
14
|
|
|
12
15
|
const cursorsPath = (id: string): string => sessionArtifactPath(id, 'cursors.json')
|
|
13
16
|
const at = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0)
|
|
@@ -16,7 +19,7 @@ const at = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v)
|
|
|
16
19
|
// message is the honest recovery for a lost position and skipping one is not. Followed entries whose target
|
|
17
20
|
// store dir is gone are dropped here — expiry is this read, and the next write persists it.
|
|
18
21
|
export function readCursors(id: string): Cursors {
|
|
19
|
-
let raw: {
|
|
22
|
+
let raw: { follows?: unknown } | null = null
|
|
20
23
|
try { raw = JSON.parse(readFileSync(cursorsPath(id), 'utf8')) } catch { /* no cursors yet */ }
|
|
21
24
|
const follows: Record<string, number> = {}
|
|
22
25
|
const stored = raw?.follows
|
|
@@ -26,11 +29,11 @@ export function readCursors(id: string): Cursors {
|
|
|
26
29
|
follows[target] = at(pos)
|
|
27
30
|
}
|
|
28
31
|
}
|
|
29
|
-
return { version: 1,
|
|
32
|
+
return { version: 1, follows }
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
// Written whole and atomically, one field per line — the same shape as the session record, so
|
|
33
|
-
//
|
|
35
|
+
// Written whole and atomically, one field per line — the same shape as the session record, so a position stays
|
|
36
|
+
// readable by an exact whole-line match where a value regex would not be.
|
|
34
37
|
function writeCursors(id: string, cursors: Cursors): void {
|
|
35
38
|
const dir = sessionStoreDir(id)
|
|
36
39
|
mkdirSync(dir, { recursive: true })
|
|
@@ -39,22 +42,13 @@ function writeCursors(id: string, cursors: Cursors): void {
|
|
|
39
42
|
renameSync(tmp, cursorsPath(id))
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
export const inboxCursor = (id: string): number => readCursors(id).inbox
|
|
43
|
-
|
|
44
|
-
// A reader that has shown everything up to `to`. Monotonic: a stale read can leave the position too low
|
|
45
|
-
// (a message shown twice), never too high (a message lost).
|
|
46
|
-
export function advanceInbox(id: string, to: number): void {
|
|
47
|
-
const cursors = readCursors(id)
|
|
48
|
-
if (to <= cursors.inbox) return
|
|
49
|
-
writeCursors(id, { ...cursors, inbox: to })
|
|
50
|
-
}
|
|
51
|
-
|
|
52
45
|
export const followCursor = (id: string, target: string): number | null => {
|
|
53
46
|
const stored = readCursors(id).follows[target]
|
|
54
47
|
return stored === undefined ? null : stored
|
|
55
48
|
}
|
|
56
49
|
|
|
57
50
|
// Start or advance a follow. Following IS this entry existing, so the first call registers the relationship.
|
|
51
|
+
// Monotonic: a stale read can leave a position too low (an event read twice), never too high (one lost).
|
|
58
52
|
export function advanceFollow(id: string, target: string, to: number): void {
|
|
59
53
|
const cursors = readCursors(id)
|
|
60
54
|
const stored = cursors.follows[target]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { sessionStoreDir } from './layout.js'
|
|
3
|
-
import { advanceFollow, followCursor,
|
|
3
|
+
import { advanceFollow, followCursor, unreadSince } from './session-cursors.js'
|
|
4
4
|
import { timelineDisplay, timelineEvents, timelineStamp } from './session-timeline.js'
|
|
5
5
|
import { sessionLabel, type DisplayStatus, type Session } from './sessions.js'
|
|
6
6
|
|
|
@@ -158,12 +158,12 @@ export async function followSessions(emit: (line: string) => void, opts: FollowO
|
|
|
158
158
|
state.delete(id)
|
|
159
159
|
emit(`${tag}[spex] closed · removed [id ${id}]`)
|
|
160
160
|
}
|
|
161
|
-
// THE
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
161
|
+
// THE FOLLOWER'S OWN LOG — watched exactly like any other target, on its own entry in `cursors.json`. It is
|
|
162
|
+
// a WATCH, not a delivery: a message reaches the agent as a prompt through the adapter ([[delivery-queue]]),
|
|
163
|
+
// so this position only decides what THIS process has already reported and can never make an agent miss
|
|
164
|
+
// mail. Never advanced here in take mode — the waiter stops on the event and the next wait resumes on it.
|
|
165
165
|
if (self && existsSync(sessionStoreDir(self))) {
|
|
166
|
-
const mine = unreadSince(timelineEvents(self),
|
|
166
|
+
const mine = unreadSince(timelineEvents(self), followCursor(self, self) ?? 0)
|
|
167
167
|
for (let k = 0; k < mine.events.length; k++) {
|
|
168
168
|
const e = mine.events[k]
|
|
169
169
|
if (e.kind !== 'sent') continue
|
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { adapterLoadedReferenceState, defaultHarness, HARNESSES, sessionIdentity
|
|
|
12
12
|
import { materialize } from './materialize.js'
|
|
13
13
|
import { mainBranch, mainRoot, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, rawLaunchReadinessOriginal, readAliasedRawRecord, readRecordEntry, readAliasedRecordEntry, readPublicRecordEntry, envSessionId, isSessionLifecycle, isSessionProposal, type PublicRecordEntry, type RawRecord, type SessionLifecycle, type SessionProposal } from './layout.js'
|
|
14
14
|
import { appendSent, recordStatus, lastHumanSendVia } from './session-timeline.js'
|
|
15
|
+
import { drain, enqueue, owesDelivery } from './delivery-queue.js'
|
|
15
16
|
import { stripRefSigil } from './mentions.js'
|
|
16
17
|
import { shQuote } from './sh.js'
|
|
17
18
|
import { assertSessionStopSafe, ResourceConflict } from './host-resources.js'
|
|
@@ -1223,6 +1224,28 @@ export function superviseQueue(intervalMs = 3000): void {
|
|
|
1223
1224
|
void tick()
|
|
1224
1225
|
}
|
|
1225
1226
|
|
|
1227
|
+
let supervisingDelivery = false
|
|
1228
|
+
// @@@ superviseDelivery - the RETRY half of [[delivery-queue]]. `sendText` hands over in its own process, which
|
|
1229
|
+
// covers the live case; this covers everything that could not be handed over then — a harness mid-restart, a
|
|
1230
|
+
// pane in the one state that swallows prompts, a session that was offline when the message arrived. Owned by
|
|
1231
|
+
// the serve that serves this project root, so a message owed to a worker is delivered when the worker can take
|
|
1232
|
+
// it rather than when it happens to run a tool. A tick with nothing owed is one existsSync per session, and
|
|
1233
|
+
// concurrent serves are harmless: the queue's lock, not the process, is what makes a handover exactly-once.
|
|
1234
|
+
export function superviseDelivery(intervalMs = 2000): void {
|
|
1235
|
+
if (supervisingDelivery) return
|
|
1236
|
+
supervisingDelivery = true
|
|
1237
|
+
const tick = async () => {
|
|
1238
|
+
try {
|
|
1239
|
+
for (const id of listSessionIds()) {
|
|
1240
|
+
if (!owesDelivery(id)) continue
|
|
1241
|
+
try { await drainSession(id) } catch { /* an adapter that refused stays owed; next tick retries */ }
|
|
1242
|
+
}
|
|
1243
|
+
} catch { /* transient store read; next tick retries */ }
|
|
1244
|
+
setTimeout(tick, intervalMs).unref()
|
|
1245
|
+
}
|
|
1246
|
+
void tick()
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1226
1249
|
type TurnFailureObserverState = {
|
|
1227
1250
|
fingerprint: string
|
|
1228
1251
|
subscription: FailureSubscription | null
|
|
@@ -3092,12 +3115,11 @@ export function formatTable(sessions: Session[], color = true): string {
|
|
|
3092
3115
|
return [c('1', `SpexCode sessions (${sessions.length})`), header, ...rows, statusLegend(color)].join('\n')
|
|
3093
3116
|
}
|
|
3094
3117
|
|
|
3095
|
-
// @@@ sendText - THE APPEND
|
|
3096
|
-
//
|
|
3097
|
-
//
|
|
3098
|
-
//
|
|
3099
|
-
//
|
|
3100
|
-
// what genuinely cannot be recorded: an unknown session id, or a log that refuses the write.
|
|
3118
|
+
// @@@ sendText - THE APPEND ACCEPTS, THE QUEUE OWES ([[dispatch]]). One hold of the record lock records the
|
|
3119
|
+
// message in the durable log AND enqueues it ([[delivery-queue]]); success is decided by that write, so a
|
|
3120
|
+
// sender learns whether the message was accepted and never whether a socket was reachable. The handover is a
|
|
3121
|
+
// separate act: drain the queue into the harness adapter as an ordinary prompt. What stays LOUD is only what
|
|
3122
|
+
// genuinely cannot be recorded: an unknown session id, or a log that refuses the write.
|
|
3101
3123
|
// A RETIRED session (worktree gone) still receives: the record gate governs the lifecycle axis, and a message
|
|
3102
3124
|
// that cannot reach an agent must at least leave a trace ([[session-timeline]]).
|
|
3103
3125
|
// (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
|
|
@@ -3105,37 +3127,44 @@ export async function sendText(id: string, text: string, from?: string, opts: {
|
|
|
3105
3127
|
if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
|
|
3106
3128
|
const rec = readRecord(id)
|
|
3107
3129
|
if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
|
|
3130
|
+
// Composed at ACCEPT time, once: the log keeps the raw conversational text plus the effective reply channel,
|
|
3131
|
+
// the queue keeps the transport form. Composing again at handover would let a later send change the hints on
|
|
3132
|
+
// a message that was already accepted.
|
|
3108
3133
|
const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
|
|
3109
|
-
let sent: { mid: string }
|
|
3110
3134
|
try {
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3135
|
+
await withRecordLock(id, async () => {
|
|
3136
|
+
const appended = appendSent(id, text, from ?? null, prompt.replyVia)
|
|
3137
|
+
enqueue(id, { mid: appended.mid, text: prompt.text, from: from ?? null })
|
|
3138
|
+
})
|
|
3114
3139
|
} catch (error) {
|
|
3115
3140
|
return { ok: false, error: `could not append the message to session ${id}'s log: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered` }
|
|
3116
3141
|
}
|
|
3117
|
-
|
|
3118
|
-
//
|
|
3119
|
-
//
|
|
3120
|
-
|
|
3121
|
-
// durable line.
|
|
3122
|
-
await pokeAdapter(h, rec, prompt.text, sent.mid)
|
|
3142
|
+
// Awaited, not fire-and-forget: an unawaited insert can lose its race with a short-lived caller's exit,
|
|
3143
|
+
// costing that send its same-turn arrival. Draining HERE rather than leaving it to the sweep is what puts
|
|
3144
|
+
// the text in a live agent's current turn instead of up to one tick later.
|
|
3145
|
+
await drainSession(id)
|
|
3123
3146
|
return { ok: true }
|
|
3124
3147
|
}
|
|
3125
3148
|
|
|
3126
|
-
//
|
|
3127
|
-
//
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
if (
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3149
|
+
// @@@ drainSession - hand over what this session is owed, as ordinary prompts. Safe to call from anywhere and
|
|
3150
|
+
// at any time: the queue's own lock serializes concurrent passes, and an empty queue costs one existsSync.
|
|
3151
|
+
// The retry sweep in `serve` calls this for the sessions whose queues an earlier pass could not empty.
|
|
3152
|
+
export async function drainSession(id: string): Promise<void> {
|
|
3153
|
+
if (!owesDelivery(id)) return
|
|
3154
|
+
const rec = readRecord(id)
|
|
3155
|
+
if (!rec) return
|
|
3156
|
+
const h = harnessById(rec.harness || defaultHarness.id)
|
|
3157
|
+
await drain(id, async (msg) => {
|
|
3158
|
+
// the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
|
|
3159
|
+
// prompt its channel confirms (claude's sessions panel), checkable only from the pane. Treated as a REFUSAL
|
|
3160
|
+
// rather than a skip — the message stays owed and the sweep hands it over once the pane leaves that state.
|
|
3161
|
+
if (h.deliveryBlockedBy) {
|
|
3162
|
+
try {
|
|
3163
|
+
if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS))) return false
|
|
3164
|
+
} catch { /* no pane to consult — let the insert itself decide */ }
|
|
3165
|
+
}
|
|
3166
|
+
return (await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.mid }, msg.text)).ok
|
|
3167
|
+
})
|
|
3139
3168
|
}
|
|
3140
3169
|
|
|
3141
3170
|
// Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness without a
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# @@@ mark-active - the SINGLE turn-boundary hook, wired to BOTH UserPromptSubmit and PreToolUse. It
|
|
3
|
-
#
|
|
4
|
-
#
|
|
2
|
+
# @@@ mark-active - the SINGLE turn-boundary hook, wired to BOTH UserPromptSubmit and PreToolUse. It has ONE
|
|
3
|
+
# job, keyed off the session's global record dir: keep the declared FRESHNESS state honest. It carries no
|
|
4
|
+
# conversation — a message reaches this agent as an ordinary prompt through the harness adapter
|
|
5
|
+
# ([[delivery-queue]]), which is the only way anything enters a turn. A hook that also injected mail handed
|
|
6
|
+
# every message over twice and made the agent's context depend on which of two paths won a race.
|
|
5
7
|
# Freshness branches on ONE structured signal read straight from the hook payload (stdin JSON), so the state is
|
|
6
8
|
# HARD — never text-sniffed from the TUI:
|
|
7
9
|
# the agent is pausing to ask the HUMAN (hp_is_ask) → status: asking, with the question text as the note
|
|
@@ -36,44 +38,6 @@ rec="$sdir/session.json"
|
|
|
36
38
|
# board-lifecycle gate: only a GOVERNED (dashboard-launched) session has a board state to maintain.
|
|
37
39
|
grep -q '^[[:space:]]*"governed"[[:space:]]*:[[:space:]]*true,\?$' "$rec" 2>/dev/null || exit 0
|
|
38
40
|
|
|
39
|
-
# @@@ the mail read - this hook is the ONE reader of the session's inbox ([[session-timeline]]): a message is
|
|
40
|
-
# delivered by being appended to timeline.ndjson, and the agent finds it here, by mechanism, at a turn
|
|
41
|
-
# boundary — never by remembering to run a command. `cursors.json` names how far this session has been shown
|
|
42
|
-
# ([[session-cursors]]); everything past it is unread, the `sent` lines among it are printed as context, and
|
|
43
|
-
# the cursor advances past ALL of it (a session's own status lines are not mail, but they are consumed, so
|
|
44
|
-
# they can never come back as one).
|
|
45
|
-
# Pure bash builtins, no forks: the common case is "no new lines", and the fast path must not cost a spawn.
|
|
46
|
-
# The read is generous where it cannot be exact — an unparseable cursor reads as 0, which re-shows a message
|
|
47
|
-
# rather than skipping one.
|
|
48
|
-
mail() {
|
|
49
|
-
local tl="$sdir/timeline.ndjson" cur="$sdir/cursors.json" pos=0 i=0 line body unread=""
|
|
50
|
-
[ -f "$tl" ] || return 0
|
|
51
|
-
if [ -f "$cur" ]; then
|
|
52
|
-
while IFS= read -r line; do
|
|
53
|
-
case "$line" in
|
|
54
|
-
*'"inbox":'*) line="${line#*\"inbox\":}"; line="${line%%,*}"; line="${line// /}"; line="${line//$'\t'/}"
|
|
55
|
-
case "$line" in ''|*[!0-9]*) ;; *) pos=$line ;; esac; break ;;
|
|
56
|
-
esac
|
|
57
|
-
done < "$cur"
|
|
58
|
-
fi
|
|
59
|
-
while IFS= read -r line; do
|
|
60
|
-
if [ "$i" -ge "$pos" ]; then
|
|
61
|
-
case "$line" in
|
|
62
|
-
*'"kind":"sent"'*) body=$(hp_field "$line" text)
|
|
63
|
-
[ -n "$body" ] && unread="$unread$body"$'\n\n' ;;
|
|
64
|
-
esac
|
|
65
|
-
fi
|
|
66
|
-
i=$((i + 1))
|
|
67
|
-
done < "$tl"
|
|
68
|
-
# No mail → no cursor write. Unread STATUS lines alone leave the cursor where it is: they print nothing, so
|
|
69
|
-
# advancing past them would buy nothing and cost a spawn on almost every turn. The next scan re-reads them
|
|
70
|
-
# and still prints nothing.
|
|
71
|
-
[ -n "$unread" ] || return 0
|
|
72
|
-
printf 'Messages addressed to you (delivered to your session log while you were working):\n\n%s' "$unread"
|
|
73
|
-
${SPEX:-spex} internal session-cursor inbox --session "$sid" --to "$i" >/dev/null 2>&1
|
|
74
|
-
}
|
|
75
|
-
mail
|
|
76
|
-
|
|
77
41
|
# does FIELD's line hold exactly VALUE? The record is written one-field-per-line by the single writer
|
|
78
42
|
# (sessions.ts writeRecord), so a whole-line match is exact — and, unlike a value regex, it cannot be fooled
|
|
79
43
|
# by an escaped quote inside a neighbouring note.
|
|
@@ -17,6 +17,6 @@ The one activity that does NOT count as the session acting is an IN-PROCESS SUBA
|
|
|
17
17
|
|
|
18
18
|
It is a board-lifecycle hook, so it acts only on a GOVERNED (dashboard-launched) session — it resolves that session's record in the global per-session store from the payload's `session_id` and no-ops unless `governed: true`. The state it writes lives in that record's `session.json` (state), but it never edits that file itself: it READS it in pure shell (whole-line matches, the hot path stays jq-free) and hands every write to `spex internal session-state`, the one structured writer the CLI declarations use — an asking note is arbitrary prose, and a shell that substitutes prose into existing JSON eventually writes a record nothing can parse.
|
|
19
19
|
|
|
20
|
-
This hook
|
|
20
|
+
This hook carries no conversation. A message addressed to the session reaches its agent as an ordinary prompt through the harness adapter (delivery-queue), which is the only way anything enters a turn, so an inter-agent message is indistinguishable from a human one at the point of arrival. A hook that also injected mail delivered every message a second time and made the agent's context depend on which of two paths won a race; a freshness signal reports a fact about the session and hands nothing over.
|
|
21
21
|
|
|
22
22
|
This is the freshness half of the [[core]] discipline: it keeps the board honest about whether a session is working, waiting, or asking, so the gates and the dashboard read a true present state rather than a stale one.
|