@spexcode/spec-cli 0.7.0-next.0 → 0.7.0-next.10
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/dist/client.js +7 -5
- package/dist/codex-runtime-generations.d.ts +4 -0
- package/dist/codex-runtime-generations.js +12 -0
- package/dist/graphSnapshot.js +4 -1
- package/dist/harness.d.ts +1 -0
- package/dist/harness.js +48 -8
- package/dist/host-resources.js +55 -3
- package/dist/materialize.js +54 -1
- package/dist/session-transcript.d.ts +1 -0
- package/dist/sessions.js +116 -13
- package/package.json +7 -7
- package/templates/spec/project/.plugins/skills/merge/spec.md +9 -3
package/dist/client.js
CHANGED
|
@@ -132,10 +132,10 @@ const post = (body) => ({ method: 'POST', headers: { 'content-type': 'applicatio
|
|
|
132
132
|
const seg = (id) => encodeURIComponent(id);
|
|
133
133
|
// A rendezvous liveness probe can displace the real delivery client.
|
|
134
134
|
function cachedStatus(rec) {
|
|
135
|
-
if (!rec.worktreePath || !existsSync(rec.worktreePath))
|
|
136
|
-
return 'retired';
|
|
137
135
|
if (rec.archived)
|
|
138
136
|
return 'offline';
|
|
137
|
+
if (!rec.worktreePath || !existsSync(rec.worktreePath))
|
|
138
|
+
return 'retired';
|
|
139
139
|
if (rec.status === 'awaiting')
|
|
140
140
|
return displayStatusForProposal(rec.proposal);
|
|
141
141
|
return rec.status === 'active' || rec.status === 'idle' ? 'unknown' : rec.status;
|
|
@@ -179,9 +179,11 @@ export function localCachedSessions(includeArchived = false) {
|
|
|
179
179
|
if (!state)
|
|
180
180
|
continue;
|
|
181
181
|
const lifecycle = state.status;
|
|
182
|
-
const status = state.status === '
|
|
183
|
-
?
|
|
184
|
-
: state.status === '
|
|
182
|
+
const status = state.status === 'archived'
|
|
183
|
+
? 'offline'
|
|
184
|
+
: state.status === 'awaiting'
|
|
185
|
+
? displayStatusForProposal(state.proposal)
|
|
186
|
+
: state.status;
|
|
185
187
|
rows.push({
|
|
186
188
|
id,
|
|
187
189
|
node: null,
|
|
@@ -46,6 +46,10 @@ export declare function prepareCodexGenerationClose(root: string, sessionId: str
|
|
|
46
46
|
export declare function repinCodexGeneration(root: string, sessionId: string, threadId: string, generationId: string): void;
|
|
47
47
|
export declare function resolveCodexGenerationForResume(root: string, sessionId: string, threadId: string, start: (endpoint: CodexGenerationEndpoint) => Promise<void>): Promise<CodexGenerationEndpoint | null>;
|
|
48
48
|
export declare function resolveCodexGenerationForSession(root: string, sessionId: string, threadId: string): CodexGenerationEndpoint | null;
|
|
49
|
+
export declare function resolveCodexGenerationForClose(root: string, sessionId: string, threadId: string): {
|
|
50
|
+
endpoint: CodexGenerationEndpoint;
|
|
51
|
+
gone: boolean;
|
|
52
|
+
} | null;
|
|
49
53
|
export declare function codexGenerationBindingForSession(root: string, sessionId: string): CodexGenerationBinding | null;
|
|
50
54
|
export declare function currentCodexGeneration(root: string): CodexGenerationEndpoint | null;
|
|
51
55
|
export declare function codexGenerationEndpoints(root: string): CodexGenerationEndpoint[];
|
|
@@ -765,6 +765,18 @@ export function resolveCodexGenerationForSession(root, sessionId, threadId) {
|
|
|
765
765
|
const generation = ledger.generations[binding.generationId];
|
|
766
766
|
return generation && generation.state !== 'reclaimed' ? generation.endpoint : null;
|
|
767
767
|
}
|
|
768
|
+
// Close still needs the immutable endpoint record to recognize a positively retired generation. The
|
|
769
|
+
// endpoint is never used for native I/O in this state; it only identifies the empty control plane.
|
|
770
|
+
export function resolveCodexGenerationForClose(root, sessionId, threadId) {
|
|
771
|
+
const ledger = readCodexGenerationLedger(root);
|
|
772
|
+
const binding = ledger.bindings[sessionId];
|
|
773
|
+
if (!binding || binding.threadId !== threadId)
|
|
774
|
+
return null;
|
|
775
|
+
const generation = ledger.generations[binding.generationId];
|
|
776
|
+
if (!generation)
|
|
777
|
+
return null;
|
|
778
|
+
return { endpoint: generation.endpoint, gone: generation.state === 'reclaimed' };
|
|
779
|
+
}
|
|
768
780
|
export function codexGenerationBindingForSession(root, sessionId) {
|
|
769
781
|
return readCodexGenerationLedger(root).bindings[sessionId] ?? null;
|
|
770
782
|
}
|
package/dist/graphSnapshot.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import { loadSpecs, requireGitWorkspace, headSha } from '@spexcode/spec-core';
|
|
4
4
|
import { resolveLayout } from '@spexcode/spec-core';
|
|
5
5
|
import { listSessions } from './sessions.js';
|
|
6
|
-
import { driftIndex, historyIndex, repoRoot } from '@spexcode/spec-core';
|
|
6
|
+
import { driftIndex, historyIndex, pruneHistoryCaches, repoRoot } from '@spexcode/spec-core';
|
|
7
7
|
import { residentForgeRevision, residentForgeState } from '@spexcode/spec-forge/resident';
|
|
8
8
|
import { resolveForgeHost } from '@spexcode/spec-forge/drivers';
|
|
9
9
|
import { boardThreads } from './issues.js';
|
|
@@ -56,6 +56,9 @@ export async function boardSnapshot() {
|
|
|
56
56
|
const root = repoRoot();
|
|
57
57
|
requireGitWorkspace(root);
|
|
58
58
|
const [specs, sessions] = await Promise.all([loadSpecs(), listSessions()]);
|
|
59
|
+
// Session worktrees are the live-root census for immutable history caches. Reconcile before the snapshot
|
|
60
|
+
// returns so closing a session releases its full index immediately rather than waiting for an LRU slot.
|
|
61
|
+
pruneHistoryCaches([root, ...sessions.map((session) => session.path)]);
|
|
59
62
|
const layout = await resolveLayout({ activeSessionIds: sessions.map((session) => session.id) });
|
|
60
63
|
const nodeIds = [...new Set([
|
|
61
64
|
...specs.map((node) => node.id),
|
package/dist/harness.d.ts
CHANGED
package/dist/harness.js
CHANGED
|
@@ -18,7 +18,8 @@ import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath, spexcodeHom
|
|
|
18
18
|
import { git } from '@spexcode/spec-core';
|
|
19
19
|
import { shQuote } from './sh.js';
|
|
20
20
|
import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime } from '@spexcode/spec-core';
|
|
21
|
-
import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, resolveCodexGenerationForSession } from './codex-runtime-generations.js';
|
|
21
|
+
import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, prepareCodexGenerationClose, resolveCodexGenerationForClose, resolveCodexGenerationForResume, resolveCodexGenerationForSession } from './codex-runtime-generations.js';
|
|
22
|
+
import { spawnDetachedRuntime } from './runtime-ownership.js';
|
|
22
23
|
import { writeFileIfChanged } from './file-write.js';
|
|
23
24
|
import { claudeTranscript, codexRolloutPath, codexTranscript, opencodeTranscript, piTranscript, unsupportedTranscript } from '@spexcode/transcript';
|
|
24
25
|
import { harnessIdentity, HARNESS_IDENTITIES } from '@spexcode/spec-core';
|
|
@@ -220,12 +221,14 @@ function codexMutationGeneration(dir = runtimeRoot(), endpoint = legacyCodexGene
|
|
|
220
221
|
return codexRuntimeGeneration(dir, endpoint);
|
|
221
222
|
}
|
|
222
223
|
const codexDescriptorKey = (endpoint) => endpoint.id === 'legacy' ? 'codex-app-server' : `codex-app-server:${endpoint.id}`;
|
|
223
|
-
function codexEndpointForRecord(rec, dir = runtimeRoot()) {
|
|
224
|
+
function codexEndpointForRecord(rec, dir = runtimeRoot(), includeGone = false) {
|
|
224
225
|
if (!rec.harnessSessionId)
|
|
225
226
|
return null;
|
|
226
227
|
const ledger = readCodexGenerationLedger(dir);
|
|
227
228
|
if (ledger.revision === 0 && !ledger.current && !Object.keys(ledger.generations).length)
|
|
228
229
|
return legacyCodexGenerationEndpoint(dir);
|
|
230
|
+
if (includeGone)
|
|
231
|
+
return resolveCodexGenerationForClose(dir, rec.session, rec.harnessSessionId)?.endpoint ?? null;
|
|
229
232
|
return resolveCodexGenerationForSession(dir, rec.session, rec.harnessSessionId);
|
|
230
233
|
}
|
|
231
234
|
// the spex launcher (bin/spex.mjs), baked into the codex launch script (mirrors materialize.ts's SPEX) so
|
|
@@ -2280,7 +2283,21 @@ async function deliverViaCodexAppServer(rec, text) {
|
|
|
2280
2283
|
// the socket is PER-PROJECT (the runtime root), shared by every worktree's thread; the owned thread id on
|
|
2281
2284
|
// the record picks out THIS session's thread.
|
|
2282
2285
|
const runtimeDir = rec.runtimeDir ?? runtimeRoot();
|
|
2283
|
-
|
|
2286
|
+
let endpoint = rec.harnessSessionId ? codexEndpointForRecord(rec, runtimeDir) : currentCodexGeneration(runtimeDir);
|
|
2287
|
+
// A generation may be reclaimed after rotation or a host restart while the session record remains valid.
|
|
2288
|
+
// Repair that stale route at the delivery boundary using the same exact-thread re-pin used by resume, so an
|
|
2289
|
+
// accepted message is not left indefinitely in the queue just because no later human resume was requested.
|
|
2290
|
+
if ((!endpoint || !existsSync(endpoint.socketPath)) && rec.harnessSessionId) {
|
|
2291
|
+
const command = codexBaseCmd(rec.launchCmd || 'codex');
|
|
2292
|
+
const env = { ...process.env };
|
|
2293
|
+
for (const key of sessionIdentityEnvVars())
|
|
2294
|
+
delete env[key];
|
|
2295
|
+
const start = async (candidate) => {
|
|
2296
|
+
await spawnDetachedRuntime({ cwd: runtimeDir, logFile: candidate.logFile, pidFile: candidate.pidFile,
|
|
2297
|
+
receiptFile: candidate.receiptFile, command, args: ['app-server', '--listen', `unix://${candidate.socketPath}`], env });
|
|
2298
|
+
};
|
|
2299
|
+
endpoint = await resolveCodexGenerationForResume(runtimeDir, rec.session, rec.harnessSessionId, start);
|
|
2300
|
+
}
|
|
2284
2301
|
if (!endpoint)
|
|
2285
2302
|
return { ok: false, error: `no exact Codex generation binding for session ${rec.session} — immediate poke unavailable` };
|
|
2286
2303
|
const sock = endpoint.socketPath;
|
|
@@ -2295,6 +2312,14 @@ async function deliverViaCodexAppServer(rec, text) {
|
|
|
2295
2312
|
return { ok: false, error: `${r.error} — immediate poke unavailable` };
|
|
2296
2313
|
threadId = r.threadId;
|
|
2297
2314
|
}
|
|
2315
|
+
const delivered = await sendCodexAppServerTurn(sock, threadId, text, rec.worktreePath, rec.mid);
|
|
2316
|
+
if (delivered.ok || rec.harness !== 'codex-headless' || !/not loaded in the app-server/u.test(delivered.error || ''))
|
|
2317
|
+
return delivered;
|
|
2318
|
+
// Headless Codex has no TUI resume step. An idle thread can be evicted from the shared server's loaded set;
|
|
2319
|
+
// reload the exact rollout, then retry the same turn once. This is idempotent and does not create a new thread.
|
|
2320
|
+
const reopened = await codexReopenThread(sock, threadId);
|
|
2321
|
+
if (!reopened.ok)
|
|
2322
|
+
return { ok: false, error: `${delivered.error}; ${reopened.error}` };
|
|
2298
2323
|
return sendCodexAppServerTurn(sock, threadId, text, rec.worktreePath, rec.mid);
|
|
2299
2324
|
}
|
|
2300
2325
|
// idempotent replace of the content between sentinels; the user's own content above/below is preserved. The
|
|
@@ -2784,7 +2809,9 @@ const socketListenerLiveness = (_rec, tmuxAlive, _runtimeDir, _pane, socketLive)
|
|
|
2784
2809
|
const socketListenerOrPidAliveLiveness = (_rec, tmuxAlive, _runtimeDir, pane, socketLive) => (tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline');
|
|
2785
2810
|
const panePidLiveness = (_rec, tmuxAlive, _runtimeDir, pane) => (tmuxAlive && pane?.pidAlive === true ? 'online' : 'offline');
|
|
2786
2811
|
const recordOnline = (rec) => rec.stopped ? 'offline' : 'online';
|
|
2787
|
-
|
|
2812
|
+
// Leaf-backed headless sessions are the controller process, not the tmux pane that hosts it. The pane can
|
|
2813
|
+
// survive a SIGKILL as a bare shell, so tmux presence without the launch-registered PID is never online.
|
|
2814
|
+
const sessionHomeLiveness = (_rec, tmuxAlive, _runtimeDir, pane) => (tmuxAlive && pane?.pidAlive === true ? 'online' : 'offline');
|
|
2788
2815
|
// @@@ unlinkSocks - remove ONLY the transport this teardown PROVED dead. `cleanupRuntime` unlinks *their*
|
|
2789
2816
|
// socket, and the honest test of "theirs" is that the agent it just killed is GONE. It used to unlink on
|
|
2790
2817
|
// faith, which is unsound because a socket path is derived from the session id ALONE: it is the one
|
|
@@ -2889,6 +2916,7 @@ export const claudeHeadlessHarness = {
|
|
|
2889
2916
|
paneTitleIsSelfSummary: false,
|
|
2890
2917
|
launchCmd: (id, runtimeDir, cmd) => claudeHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), claudeBaseCmd(cmd)),
|
|
2891
2918
|
launchEnv: noLaunchEnv,
|
|
2919
|
+
// The controller's registered PID is the liveness witness; the tmux home is only the address boundary.
|
|
2892
2920
|
liveness: sessionHomeLiveness,
|
|
2893
2921
|
deliveryTransport: unprovenDeliveryTransport,
|
|
2894
2922
|
deliver: deliverViaClaudeHeadless,
|
|
@@ -3025,7 +3053,7 @@ export const codexHarness = {
|
|
|
3025
3053
|
interrupt: interruptCodexTurn,
|
|
3026
3054
|
cleanupRuntime: async () => { },
|
|
3027
3055
|
targetDescriptorKey: (rec) => {
|
|
3028
|
-
const endpoint = codexEndpointForRecord(rec);
|
|
3056
|
+
const endpoint = codexEndpointForRecord(rec, runtimeRoot(), true);
|
|
3029
3057
|
return endpoint ? codexDescriptorKey(endpoint) : null;
|
|
3030
3058
|
},
|
|
3031
3059
|
coldRetirementPreflight: async (rec) => {
|
|
@@ -3049,8 +3077,13 @@ export const codexHarness = {
|
|
|
3049
3077
|
coldPreflight: async (rec) => {
|
|
3050
3078
|
if (!rec.harnessSessionId)
|
|
3051
3079
|
return { ok: false, reason: 'no exact Codex thread identity is registered' };
|
|
3052
|
-
const
|
|
3053
|
-
|
|
3080
|
+
const dir = runtimeRoot();
|
|
3081
|
+
const binding = resolveCodexGenerationForClose(dir, rec.session, rec.harnessSessionId);
|
|
3082
|
+
if (binding?.gone) {
|
|
3083
|
+
return { ok: true, alreadyCold: true };
|
|
3084
|
+
}
|
|
3085
|
+
const endpoint = binding?.endpoint ?? codexEndpointForRecord(rec, dir);
|
|
3086
|
+
return endpoint ? codexColdPreflight(rec.harnessSessionId, dir, undefined, endpoint)
|
|
3054
3087
|
: { ok: false, reason: 'no exact Codex generation binding is registered for this target' };
|
|
3055
3088
|
},
|
|
3056
3089
|
coldRuntime: async (rec, suppliedReceipt) => {
|
|
@@ -3058,7 +3091,12 @@ export const codexHarness = {
|
|
|
3058
3091
|
return { ok: false, reason: 'no exact Codex thread identity is registered' };
|
|
3059
3092
|
const threadId = rec.harnessSessionId;
|
|
3060
3093
|
const dir = runtimeRoot();
|
|
3061
|
-
const
|
|
3094
|
+
const binding = resolveCodexGenerationForClose(dir, rec.session, threadId);
|
|
3095
|
+
if (binding?.gone) {
|
|
3096
|
+
prepareCodexGenerationClose(dir, rec.session, threadId);
|
|
3097
|
+
return { ok: true };
|
|
3098
|
+
}
|
|
3099
|
+
const endpoint = binding?.endpoint ?? codexEndpointForRecord(rec, dir);
|
|
3062
3100
|
if (!endpoint)
|
|
3063
3101
|
return { ok: false, reason: 'no exact Codex generation binding is registered for this target' };
|
|
3064
3102
|
const sock = endpoint.socketPath;
|
|
@@ -3400,6 +3438,7 @@ export const piHeadlessHarness = {
|
|
|
3400
3438
|
runtimeOwnership: 'leaf',
|
|
3401
3439
|
paneTitleIsSelfSummary: false,
|
|
3402
3440
|
launchCmd: (id, runtimeDir, cmd) => piHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), piBaseCmd(cmd)),
|
|
3441
|
+
// The controller's registered PID is the liveness witness; the tmux home is only the address boundary.
|
|
3403
3442
|
liveness: sessionHomeLiveness,
|
|
3404
3443
|
deliver: deliverViaPiHeadless,
|
|
3405
3444
|
// the controller aborts its own turn child natively and confirms only once that child is gone
|
|
@@ -3513,6 +3552,7 @@ export const opencodeHeadlessHarness = {
|
|
|
3513
3552
|
runtimeOwnership: 'leaf',
|
|
3514
3553
|
deliveryTransport: unprovenDeliveryTransport,
|
|
3515
3554
|
launchCmd: (_id, _runtimeDir, cmd) => opencodeHeadlessLaunchCommand(opencodeBaseCmd(cmd)),
|
|
3555
|
+
// The controller's registered PID is the liveness witness; the tmux home is only the address boundary.
|
|
3516
3556
|
liveness: sessionHomeLiveness,
|
|
3517
3557
|
coldRuntime: async (rec) => {
|
|
3518
3558
|
const result = await opencodeHeadlessColdRuntime(rec);
|
package/dist/host-resources.js
CHANGED
|
@@ -145,6 +145,23 @@ const descendants = (root, procs) => {
|
|
|
145
145
|
}
|
|
146
146
|
return ids;
|
|
147
147
|
};
|
|
148
|
+
// tmux is a shared host for many independent session windows. Its inherited environment is therefore
|
|
149
|
+
// not a launch receipt: a token on the server or one of its panes cannot charge the whole hosted tree.
|
|
150
|
+
const isTmuxServer = (proc) => proc?.command === 'tmux: server';
|
|
151
|
+
const hasTmuxAncestor = (pid, procs) => {
|
|
152
|
+
const seen = new Set();
|
|
153
|
+
let next = procs.get(pid)?.ppid;
|
|
154
|
+
while (next && !seen.has(next)) {
|
|
155
|
+
seen.add(next);
|
|
156
|
+
const parent = procs.get(next);
|
|
157
|
+
if (!parent)
|
|
158
|
+
return false;
|
|
159
|
+
if (isTmuxServer(parent))
|
|
160
|
+
return true;
|
|
161
|
+
next = parent.ppid;
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
};
|
|
148
165
|
const publicRecordInventory = () => {
|
|
149
166
|
const entries = listSessionIds().map(readPublicRecordEntry);
|
|
150
167
|
const application = configuredSessionApplicationIfCutover();
|
|
@@ -216,6 +233,29 @@ const unresolvedHarnessOwners = (recs, budgets, have) => {
|
|
|
216
233
|
}
|
|
217
234
|
return out;
|
|
218
235
|
};
|
|
236
|
+
// A leaf-backed headless controller is the session's runtime. Keep its durable row visible when the controller
|
|
237
|
+
// disappears before the next resources sweep can attribute a process, and name the status/liveness contradiction
|
|
238
|
+
// explicitly so a supervisor cannot mistake the missing process for a cleanly idle session.
|
|
239
|
+
const missingHeadlessControllerOwners = (recs, procs, budgets, have) => {
|
|
240
|
+
const out = [];
|
|
241
|
+
for (const rec of recs) {
|
|
242
|
+
const harness = harnessByIdOrNull(rec.harness || defaultHarness.id);
|
|
243
|
+
if (!rec.governed || rec.stopped || rec.archived || rec.status !== 'active' && rec.status !== 'idle'
|
|
244
|
+
|| harness?.runtimeOwnership !== 'leaf' || have(rec.session_id))
|
|
245
|
+
continue;
|
|
246
|
+
const pid = runtimePid(join(runtimeRoot(), 'sessions', rec.session_id, 'agent.pid'));
|
|
247
|
+
if (pid && procs.has(pid))
|
|
248
|
+
continue;
|
|
249
|
+
out.push({
|
|
250
|
+
kind: 'session', id: rec.session_id, label: `session ${rec.session_id.slice(0, 8)}`, status: rec.status, liveness: 'offline',
|
|
251
|
+
processes: [], rssMiB: 0, pssMiB: null, cpuPercent: 0,
|
|
252
|
+
budget: { rssMiB: budgets.sessionRssMiB, idleCpuPercent: budgets.idleCpuPercent },
|
|
253
|
+
findings: [`headless-liveness-contradiction:${rec.status}/offline`],
|
|
254
|
+
reclaim: { eligible: false, reason: 'headless controller is absent; lifecycle repair must be explicit' },
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
};
|
|
219
259
|
const sharedDescriptors = (recs, retainRegistry = false) => {
|
|
220
260
|
const out = new Map();
|
|
221
261
|
for (const harness of HARNESSES)
|
|
@@ -322,13 +362,13 @@ const buildInventory = (procs, publicRecords = publicRecordInventory()) => {
|
|
|
322
362
|
for (const p of procs.values()) {
|
|
323
363
|
const acting = actingSession(p);
|
|
324
364
|
const fallback = p.env.SPEXCODE_SESSION_ID;
|
|
325
|
-
const sid = acting ?? (fallback ? byId.get(fallback) : undefined);
|
|
365
|
+
const sid = acting ?? (!hasTmuxAncestor(p.pid, procs) && !isTmuxServer(p) && fallback ? byId.get(fallback) : undefined);
|
|
326
366
|
if (sid)
|
|
327
367
|
ownership.set(p.pid, `session:${sid}`);
|
|
328
368
|
}
|
|
329
369
|
for (const rec of activeRecs) {
|
|
330
370
|
const root = runtimePid(join(runtimeRoot(), 'sessions', rec.session_id, 'agent.pid'));
|
|
331
|
-
if (root && procs.has(root))
|
|
371
|
+
if (root && procs.has(root) && !isTmuxServer(procs.get(root)))
|
|
332
372
|
for (const pid of descendants(root, procs))
|
|
333
373
|
if (!ownership.has(pid))
|
|
334
374
|
ownership.set(pid, `session:${rec.session_id}`);
|
|
@@ -368,7 +408,7 @@ const buildInventory = (procs, publicRecords = publicRecordInventory()) => {
|
|
|
368
408
|
}
|
|
369
409
|
const root = repoRoot();
|
|
370
410
|
for (const p of procs.values()) {
|
|
371
|
-
if (ownership.has(p.pid) || p.env.SPEXCODE_PROJECT_ROOT !== root)
|
|
411
|
+
if (ownership.has(p.pid) || p.env.SPEXCODE_PROJECT_ROOT !== root || hasTmuxAncestor(p.pid, procs) || isTmuxServer(p))
|
|
372
412
|
continue;
|
|
373
413
|
const claimed = p.env.SPEXCODE_SESSION_ID;
|
|
374
414
|
if (claimed && !byId.has(claimed))
|
|
@@ -448,6 +488,17 @@ const sessionStopBlocker = async (id, harnessId, recs = rawRecords(), knownProbe
|
|
|
448
488
|
const entryTargetThread = entry.recs.find((rec) => rec.session_id === id)?.harness_session_id;
|
|
449
489
|
if (entryTargetThread && ownerCounts.get(entryTargetThread) !== 1)
|
|
450
490
|
return `${descriptor.label} target thread ${entryTargetThread} has no one exact governed session owner`;
|
|
491
|
+
// A retired shared generation is positive death, not an unknown control plane. Its residency census is
|
|
492
|
+
// the only proof needed here; there is no native target left to signal or unload.
|
|
493
|
+
if (descriptor.residency && entryTargetThread) {
|
|
494
|
+
let residency = null;
|
|
495
|
+
try {
|
|
496
|
+
residency = await descriptor.residency();
|
|
497
|
+
}
|
|
498
|
+
catch { /* retain the normal fail-closed path */ }
|
|
499
|
+
if (residency?.healthy && residency.rootAbsent === true && residency.referenceIds.length === 0)
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
451
502
|
if (!knownProbes && descriptor.mutationGuard) {
|
|
452
503
|
if (!targetThread)
|
|
453
504
|
return `${descriptor.label} target has no exact governed thread identity`;
|
|
@@ -722,6 +773,7 @@ export async function collectResourceReport(opts = {}) {
|
|
|
722
773
|
budget: { rssMiB: budgets.sessionRssMiB, idleCpuPercent: budgets.idleCpuPercent },
|
|
723
774
|
findings: [`session-record-corrupt:${entry.error}`], reclaim: { eligible: false, reason: 'session record is corrupt; ownership and lifecycle are unknown' },
|
|
724
775
|
});
|
|
776
|
+
owners.push(...missingHeadlessControllerOwners(inv.recs, inv.procs, budgets, (sessionId) => owners.some((owner) => owner.kind === 'session' && owner.id === sessionId)));
|
|
725
777
|
owners.push(...unresolvedHarnessOwners(inv.recs, budgets, (sessionId) => owners.some((owner) => owner.kind === 'session' && owner.id === sessionId)));
|
|
726
778
|
// A referenced shared runtime with no readable process is still operationally important: keep its live or
|
|
727
779
|
// unknown refcount visible instead of silently omitting it from a process-derived report.
|
package/dist/materialize.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { writeFileSync, mkdirSync, readFileSync, existsSync, readdirSync, renameSync, rmSync, rmdirSync } from 'node:fs';
|
|
1
|
+
import { writeFileSync, mkdirSync, readFileSync, existsSync, readdirSync, renameSync, rmSync, rmdirSync, copyFileSync, chmodSync } from 'node:fs';
|
|
2
2
|
import { join, dirname, relative } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
@@ -37,6 +37,58 @@ const DISPATCH = join(PKG, 'hooks', 'dispatch.sh');
|
|
|
37
37
|
// CLI code and keeps the source-workspace mid-merge guard (one line + exit 75), so every hook callback
|
|
38
38
|
// inherits both.
|
|
39
39
|
const SPEX = join(PKG, 'bin', 'spex.mjs');
|
|
40
|
+
const CORE_TEMPLATE = join(PKG, 'templates', 'spec', 'project', '.plugins', 'core');
|
|
41
|
+
// Core hook handlers are shipped executable protocol, not adopter-owned plugin variants. Reconcile only the
|
|
42
|
+
// known `core/` subtree before compiling the manifest so a project seeded by an older toolchain cannot keep
|
|
43
|
+
// invoking a retired lifecycle writer. User plugins live outside this allowlist and are never enumerated.
|
|
44
|
+
function refreshCorePluginHandlers(proj) {
|
|
45
|
+
if (!existsSync(CORE_TEMPLATE))
|
|
46
|
+
return [];
|
|
47
|
+
const specDir = join(proj, '.spec');
|
|
48
|
+
const roots = existsSync(specDir)
|
|
49
|
+
? readdirSync(specDir, { withFileTypes: true })
|
|
50
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(specDir, entry.name, '.plugins', 'core')))
|
|
51
|
+
.map((entry) => join(specDir, entry.name, '.plugins', 'core'))
|
|
52
|
+
: [];
|
|
53
|
+
const handlers = [];
|
|
54
|
+
const walk = (dir, prefix = '') => {
|
|
55
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
56
|
+
const rel = join(prefix, entry.name);
|
|
57
|
+
if (entry.isDirectory())
|
|
58
|
+
walk(join(dir, entry.name), rel);
|
|
59
|
+
else if (entry.isFile() && entry.name.endsWith('.sh'))
|
|
60
|
+
handlers.push(rel);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
walk(CORE_TEMPLATE);
|
|
64
|
+
const refreshed = [];
|
|
65
|
+
for (const root of roots)
|
|
66
|
+
for (const rel of handlers) {
|
|
67
|
+
const source = join(CORE_TEMPLATE, rel);
|
|
68
|
+
const dest = join(root, rel);
|
|
69
|
+
let current = null;
|
|
70
|
+
try {
|
|
71
|
+
current = readFileSync(dest);
|
|
72
|
+
}
|
|
73
|
+
catch { }
|
|
74
|
+
if (current?.equals(readFileSync(source)))
|
|
75
|
+
continue;
|
|
76
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
77
|
+
const temp = `${dest}.spexcode-${process.pid}`;
|
|
78
|
+
try {
|
|
79
|
+
copyFileSync(source, temp);
|
|
80
|
+
chmodSync(temp, 0o755);
|
|
81
|
+
renameSync(temp, dest);
|
|
82
|
+
refreshed.push(relative(proj, dest));
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
rmSync(temp, { force: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (refreshed.length)
|
|
89
|
+
console.log(`✓ refreshed core plugin handlers (${refreshed.join(', ')})`);
|
|
90
|
+
return refreshed;
|
|
91
|
+
}
|
|
40
92
|
// the manifest + content-hash marker + plugin-folder ledger land in the materialized TREE's own slot of the
|
|
41
93
|
// GLOBAL per-project store (layout.treeSlotDir — trees/<enc-worktree>), NOT the worktree and NOT one shared
|
|
42
94
|
// per-project file: each is a pure function of ONE tree's .plugins, and the old single slot let the last-
|
|
@@ -321,6 +373,7 @@ export function dematerialize(proj = process.cwd(), arts = { skills: [], agents:
|
|
|
321
373
|
// the whole pay-per-change materialize. proj defaults to cwd. Its receipt is populated at each successful
|
|
322
374
|
// write so callers report the actual selected footprint instead of maintaining a second artifact inventory.
|
|
323
375
|
export function materialize(proj = process.cwd()) {
|
|
376
|
+
refreshCorePluginHandlers(proj);
|
|
324
377
|
const rt = treeSlotDir(proj); // this tree's slot in the global store, not the worktree
|
|
325
378
|
mkdirSync(rt, { recursive: true });
|
|
326
379
|
const planted = [];
|
|
@@ -30,6 +30,7 @@ export declare function readSessionTranscript(c: Context): Promise<(Response & i
|
|
|
30
30
|
readonly output?: string | undefined;
|
|
31
31
|
readonly outputLines: number;
|
|
32
32
|
readonly outputBytes: number;
|
|
33
|
+
readonly outcome?: "failed" | "rejected" | undefined;
|
|
33
34
|
}[] | undefined;
|
|
34
35
|
}[];
|
|
35
36
|
readonly truncated: boolean;
|
package/dist/sessions.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { createConnection } from 'node:net';
|
|
2
3
|
import { promisify } from 'node:util';
|
|
3
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
5
|
import { readFileSync, writeFileSync, existsSync, renameSync, linkSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync, unlinkSync } from 'node:fs';
|
|
@@ -15,6 +16,7 @@ import { readSessionFiles } from './session-files.js';
|
|
|
15
16
|
import { readSessionWebs } from './session-web.js';
|
|
16
17
|
import { acquireFreshSessionApplicationForCreate, configuredSessionApplicationIfCutover, initializeFreshSessionApplication, releaseFreshSessionApplicationForCreate, sessionApplicationCutoverState, setSessionApplicationCommitWake } from './session-application.js';
|
|
17
18
|
import { jsonMigrationFencePath } from '@spexcode/session-application';
|
|
19
|
+
import { decodeEventJson } from '@spexcode/session-events';
|
|
18
20
|
import { withDeliveryLocks } from './delivery-lock.js';
|
|
19
21
|
import { withSessionRecordLock, withSessionRecordLockSync as coreWithSessionRecordLockSync } from './session-record-lock.js';
|
|
20
22
|
import { stripRefSigil } from './mentions.js';
|
|
@@ -1100,7 +1102,7 @@ export function toSession(rec, status, lv, activity = null) {
|
|
|
1100
1102
|
const pp = prompt ? oneLinePreview(prompt) : null;
|
|
1101
1103
|
const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, note: rec.note, promptPreview: pp };
|
|
1102
1104
|
const harness = harnessById(rec.harness || defaultHarness.id);
|
|
1103
|
-
return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), title: deriveTitle(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived
|
|
1105
|
+
return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), title: deriveTitle(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.closedAt ? 'archived' : rec.status, proposal: rec.closedAt ? null : rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived || !!rec.closedAt, closedAt: rec.closedAt, archiveHazard: null, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey, files: readSessionFiles(rec.session), web: readSessionWebs(rec.session), ...(rec.zcodeChildSessionIds?.length ? { zcodeChildSessionIds: [...rec.zcodeChildSessionIds] } : {}) };
|
|
1104
1106
|
}
|
|
1105
1107
|
// @@@zcode child identity - ZCode owns the child id and SpexCode owns the session record. The writer accepts
|
|
1106
1108
|
// only their exact declared pair; names, worktrees, branches, and timestamps are deliberately not candidates.
|
|
@@ -1174,7 +1176,7 @@ export async function listArchivedSessionIndex(probe) {
|
|
|
1174
1176
|
if (entry.kind !== 'ok')
|
|
1175
1177
|
continue;
|
|
1176
1178
|
const rec = fromRaw(entry.raw);
|
|
1177
|
-
if (!rec.governed || !rec.archived)
|
|
1179
|
+
if (!rec.governed || (!rec.archived && !rec.closedAt))
|
|
1178
1180
|
continue;
|
|
1179
1181
|
const parts = {
|
|
1180
1182
|
id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch,
|
|
@@ -1303,7 +1305,11 @@ export async function listSessions(includeArchived = false) {
|
|
|
1303
1305
|
// missing durable cold proof is also legacy: leaf liveness alone cannot prove a Codex loaded thread was
|
|
1304
1306
|
// unloaded, so it remains visible until an explicit archive repair.
|
|
1305
1307
|
const cleanCold = projectedRecord.archived && !changedDuringCensus.has(id) && hasValidColdProof(projectedRecord) && physical === 'offline' && (!residentRequired || resident?.healthy === true);
|
|
1306
|
-
|
|
1308
|
+
// A published close is terminal public history even if a later census cannot prove the old adapter fully
|
|
1309
|
+
// unloaded. Only legacy archived rows without closedAt may be exposed as a working hazard for repair.
|
|
1310
|
+
const projected = projectedRecord.archived && !cleanCold && !projectedRecord.closedAt
|
|
1311
|
+
? { ...projectedRecord, archived: false, stopped: false }
|
|
1312
|
+
: projectedRecord;
|
|
1307
1313
|
const projectedLv = projected === projectedRecord
|
|
1308
1314
|
? sessionHarness.runtimeOwnership === 'adapter'
|
|
1309
1315
|
? adapterResidentLiveness(projectedRecord, resident)
|
|
@@ -1704,11 +1710,45 @@ let draining = false; // re-entrancy guard: only one drain pass runs at a time (
|
|
|
1704
1710
|
// A native receipt is bound before the readiness fence validates it. Suppress only that immediate wake so
|
|
1705
1711
|
// queued prompts cannot drain during the candidate window; the successful publication path drains normally.
|
|
1706
1712
|
const readinessWakeSuppressed = new Set();
|
|
1713
|
+
// A readiness timeout is launch-phase evidence only until the session produces another durable event. The
|
|
1714
|
+
// first event at/after the readiness marker is the launch transition itself; anything after that proves the
|
|
1715
|
+
// worker progressed (including a declaration), so a late diagnostic is moot and must not replace its note.
|
|
1716
|
+
function hasLaterLaunchReadinessEvent(rec) {
|
|
1717
|
+
const startedAt = rec.launchReadinessStartedAt;
|
|
1718
|
+
if (!Number.isFinite(startedAt))
|
|
1719
|
+
return false;
|
|
1720
|
+
const application = configuredSessionApplicationIfCutover();
|
|
1721
|
+
if (!application?.readState(rec.session))
|
|
1722
|
+
return false;
|
|
1723
|
+
const events = application.readEvents(rec.session);
|
|
1724
|
+
const statusPayload = (event) => {
|
|
1725
|
+
const payload = decodeEventJson(event.payload);
|
|
1726
|
+
return payload && typeof payload === 'object' && !Array.isArray(payload) && 'status' in payload
|
|
1727
|
+
? payload : null;
|
|
1728
|
+
};
|
|
1729
|
+
const baseline = events.find((event) => {
|
|
1730
|
+
if (event.occurredAtMs < Number(startedAt))
|
|
1731
|
+
return false;
|
|
1732
|
+
const payload = statusPayload(event);
|
|
1733
|
+
return payload?.status === 'active';
|
|
1734
|
+
});
|
|
1735
|
+
return baseline ? events.some((event) => {
|
|
1736
|
+
if (event.eventSeq <= baseline.eventSeq)
|
|
1737
|
+
return false;
|
|
1738
|
+
const payload = statusPayload(event);
|
|
1739
|
+
if (!payload)
|
|
1740
|
+
return false;
|
|
1741
|
+
const note = payload.note;
|
|
1742
|
+
return !(typeof note === 'string' && /^(?:queued launch readiness failed|launch readiness warning):/.test(note));
|
|
1743
|
+
}) : false;
|
|
1744
|
+
}
|
|
1707
1745
|
function noteQueuedLaunchFailureUnlocked(id, error, terminal = true, label, live = false) {
|
|
1746
|
+
const rec = readRecord(id);
|
|
1747
|
+
if (rec && (terminal || label === 'launch readiness warning') && hasLaterLaunchReadinessEvent(rec))
|
|
1748
|
+
return;
|
|
1708
1749
|
const reason = error instanceof Error ? error.message : String(error);
|
|
1709
1750
|
const note = `${label ?? (terminal ? 'queued launch readiness failed' : 'launch readiness warning')}: ${reason}`;
|
|
1710
1751
|
console.error(`spex: session ${id}: ${note}`);
|
|
1711
|
-
const rec = readRecord(id);
|
|
1712
1752
|
if (rec && !retirementReason(rec) && (rec.note !== note
|
|
1713
1753
|
|| (terminal && (rec.status !== 'error' || !rec.stopped || rec.launchReadinessStartedAt != null))
|
|
1714
1754
|
|| (!terminal && live && (rec.status === 'error' || rec.stopped)))) {
|
|
@@ -1788,12 +1828,16 @@ export function canonicalRecordProjection(rec, canonical) {
|
|
|
1788
1828
|
// The application row is the only lifecycle fact after cutover. A JSON status is historical envelope data,
|
|
1789
1829
|
// so it must not win merely because it says waiting/error/archived while the canonical row says otherwise.
|
|
1790
1830
|
if (!canonical) {
|
|
1791
|
-
return rec
|
|
1831
|
+
return ('closedAt' in rec && rec.closedAt
|
|
1832
|
+
? { ...rec, archived: true }
|
|
1833
|
+
: rec);
|
|
1792
1834
|
}
|
|
1835
|
+
const closed = 'closedAt' in rec && !!rec.closedAt;
|
|
1793
1836
|
return {
|
|
1794
1837
|
...rec,
|
|
1795
|
-
|
|
1796
|
-
|
|
1838
|
+
archived: closed ? true : rec.archived,
|
|
1839
|
+
status: (closed ? 'archived' : canonical.status),
|
|
1840
|
+
proposal: (closed ? null : canonical.proposal),
|
|
1797
1841
|
note: canonical.note,
|
|
1798
1842
|
parent: canonical.parentSessionId,
|
|
1799
1843
|
};
|
|
@@ -2488,9 +2532,53 @@ function isExplicitConnectionRefused(error) {
|
|
|
2488
2532
|
return errors.length > 0 && errors.every(isExplicitConnectionRefused);
|
|
2489
2533
|
return isExplicitConnectionRefused(error.cause);
|
|
2490
2534
|
}
|
|
2535
|
+
async function establishBackendConnection(target) {
|
|
2536
|
+
const parsed = new URL(target.url);
|
|
2537
|
+
const port = Number(parsed.port) || (parsed.protocol === 'https:' ? 443 : 80);
|
|
2538
|
+
return await new Promise((resolve, reject) => {
|
|
2539
|
+
const socket = createConnection({ host: parsed.hostname, port });
|
|
2540
|
+
let settled = false;
|
|
2541
|
+
const finish = (fn) => {
|
|
2542
|
+
if (settled)
|
|
2543
|
+
return;
|
|
2544
|
+
settled = true;
|
|
2545
|
+
clearTimeout(timer);
|
|
2546
|
+
socket.destroy();
|
|
2547
|
+
fn();
|
|
2548
|
+
};
|
|
2549
|
+
const timer = setTimeout(() => {
|
|
2550
|
+
// The event loop may have been blocked past the wall after the kernel completed the handshake. In that
|
|
2551
|
+
// case Node has not emitted `connect` yet, but `connecting` is already false; acceptance still proves
|
|
2552
|
+
// presence and must not be relabelled as an unavailable backend.
|
|
2553
|
+
if (!socket.connecting || socket.readyState === 'open')
|
|
2554
|
+
return finish(() => resolve(false));
|
|
2555
|
+
finish(() => {
|
|
2556
|
+
const error = new Error(`backend connection was not accepted at ${target.url} within 1500ms`);
|
|
2557
|
+
error.name = 'BackendError';
|
|
2558
|
+
Object.assign(error, { code: 'backend_availability_indeterminate' });
|
|
2559
|
+
reject(error);
|
|
2560
|
+
});
|
|
2561
|
+
}, 1500);
|
|
2562
|
+
timer.unref?.();
|
|
2563
|
+
socket.once('connect', () => finish(() => resolve(false)));
|
|
2564
|
+
socket.once('error', (error) => finish(() => {
|
|
2565
|
+
if (isExplicitConnectionRefused(error))
|
|
2566
|
+
return resolve(true);
|
|
2567
|
+
const failed = new Error(`backend availability is indeterminate at ${target.url}; refusing in-process session creation (${error instanceof Error ? error.message : error})`);
|
|
2568
|
+
failed.name = 'BackendError';
|
|
2569
|
+
Object.assign(failed, { code: 'backend_availability_indeterminate', cause: error });
|
|
2570
|
+
reject(failed);
|
|
2571
|
+
}));
|
|
2572
|
+
});
|
|
2573
|
+
}
|
|
2491
2574
|
async function probeSessionCreateAuthority(target) {
|
|
2575
|
+
// TCP acceptance establishes presence. The identity route can be delayed by a busy backend event loop,
|
|
2576
|
+
// so it gets the ordinary create request deadline instead of a short availability deadline.
|
|
2577
|
+
const refused = await establishBackendConnection(target);
|
|
2578
|
+
if (refused)
|
|
2579
|
+
return true;
|
|
2492
2580
|
const controller = new AbortController();
|
|
2493
|
-
const timer = setTimeout(() => controller.abort(),
|
|
2581
|
+
const timer = setTimeout(() => controller.abort(new Error('backend authority request timed out')), sessionCreateTimeoutMs() + 5_000);
|
|
2494
2582
|
timer.unref?.();
|
|
2495
2583
|
let response;
|
|
2496
2584
|
try {
|
|
@@ -2498,11 +2586,9 @@ async function probeSessionCreateAuthority(target) {
|
|
|
2498
2586
|
}
|
|
2499
2587
|
catch (error) {
|
|
2500
2588
|
clearTimeout(timer);
|
|
2501
|
-
|
|
2502
|
-
return true;
|
|
2503
|
-
const failed = new Error(`backend availability is indeterminate at ${target.url}; refusing in-process session creation (${error instanceof Error ? error.message : error})`);
|
|
2589
|
+
const failed = new Error(`backend authority read failed after connection at ${target.url}; refusing in-process session creation (${error instanceof Error ? error.message : error})`);
|
|
2504
2590
|
failed.name = 'BackendError';
|
|
2505
|
-
Object.assign(failed, { code: '
|
|
2591
|
+
Object.assign(failed, { code: 'backend_authority_read_failed', cause: error });
|
|
2506
2592
|
throw failed;
|
|
2507
2593
|
}
|
|
2508
2594
|
try {
|
|
@@ -2988,7 +3074,7 @@ async function prepareSession(prompt, parent, launcher, name, context) {
|
|
|
2988
3074
|
try {
|
|
2989
3075
|
gitMutationStarted = true;
|
|
2990
3076
|
traceSessionCreate(id, requestDigest, phase, 'start', 'worktree-add');
|
|
2991
|
-
const added = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'worktree', 'add', '-b', branch, path, startPoint], { extraEnv: DEFER_FOOTPRINT_REFRESH }));
|
|
3077
|
+
const added = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'worktree', 'add', '--no-track', '-b', branch, path, startPoint], { extraEnv: DEFER_FOOTPRINT_REFRESH }));
|
|
2992
3078
|
traceSessionCreate(id, requestDigest, phase, 'finish', 'worktree-add');
|
|
2993
3079
|
if (added.ok)
|
|
2994
3080
|
Object.assign(owned, { path: true, worktree: true, branch: true });
|
|
@@ -4711,12 +4797,25 @@ async function closeOwnedSessionUnlocked(id, wt, _source, unboundStopped = false
|
|
|
4711
4797
|
throw new ResourceConflict(`refusing to finish close for ${id}: session record disappeared before publication`);
|
|
4712
4798
|
writeRecord({
|
|
4713
4799
|
...latest,
|
|
4800
|
+
proposal: null,
|
|
4714
4801
|
archived: true,
|
|
4715
4802
|
closedAt: latest.closedAt || new Date().toISOString(),
|
|
4716
4803
|
stopped: true,
|
|
4717
4804
|
coldProof: latest.coldProof || coldProofFor(latest),
|
|
4718
4805
|
adapterRecovery: null,
|
|
4719
4806
|
});
|
|
4807
|
+
// The canonical lifecycle must settle at the same terminal boundary as the durable close fact. `archived`
|
|
4808
|
+
// is an internal terminal marker; public projections render its closed record as `retired`.
|
|
4809
|
+
const application = configuredSessionApplicationIfCutover();
|
|
4810
|
+
if (application?.readState(id)) {
|
|
4811
|
+
application.transitionSession(id, {
|
|
4812
|
+
status: 'archived',
|
|
4813
|
+
proposal: null,
|
|
4814
|
+
note: latest.note,
|
|
4815
|
+
parentSessionId: latest.parent,
|
|
4816
|
+
recipientSessionIds: canonicalWatchRecipients(application, id, 'archived'),
|
|
4817
|
+
});
|
|
4818
|
+
}
|
|
4720
4819
|
let slot = null;
|
|
4721
4820
|
try {
|
|
4722
4821
|
slot = existsSync(wt.path) ? treeSlotDir(wt.path) : null;
|
|
@@ -5289,6 +5388,8 @@ export async function drainSession(id) {
|
|
|
5289
5388
|
const removed = application.dequeuePendingMessage(id, msg.messageId);
|
|
5290
5389
|
if (!removed || removed.messageId !== msg.messageId)
|
|
5291
5390
|
throw new ResourceConflict(`canonical queue head changed while delivering ${id}`);
|
|
5391
|
+
if (!msg.senderSessionId)
|
|
5392
|
+
markHumanPromptActive(id);
|
|
5292
5393
|
}
|
|
5293
5394
|
});
|
|
5294
5395
|
return;
|
|
@@ -5315,6 +5416,8 @@ export async function drainSession(id) {
|
|
|
5315
5416
|
const removed = application.dequeueForRuntime(id, 'spex-governed', binding.bindingGeneration, msg.messageId);
|
|
5316
5417
|
if (!removed || removed.messageId !== msg.messageId)
|
|
5317
5418
|
throw new ResourceConflict(`canonical queue head changed while delivering ${id}`);
|
|
5419
|
+
if (!msg.senderSessionId)
|
|
5420
|
+
markHumanPromptActive(id);
|
|
5318
5421
|
}
|
|
5319
5422
|
});
|
|
5320
5423
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spexcode/spec-cli",
|
|
3
|
-
"version": "0.7.0-next.
|
|
3
|
+
"version": "0.7.0-next.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SpexCode CLI + server. The root spexcode package delegates to this compiled package; dashboard assets live in @spexcode/spec-dashboard.",
|
|
6
6
|
"bin": {
|
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
"test": "tsx --import ../scripts/test-home.mjs --test src/*.test.ts"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@spexcode/session-application": "0.7.0-next.
|
|
39
|
-
"@spexcode/session-selflaunch": "0.7.0-next.
|
|
40
|
-
"@spexcode/spec-core": "0.7.0-next.
|
|
41
|
-
"@spexcode/spec-eval": "0.7.0-next.
|
|
42
|
-
"@spexcode/spec-forge": "0.7.0-next.
|
|
43
|
-
"@spexcode/transcript": "0.7.0-next.
|
|
38
|
+
"@spexcode/session-application": "0.7.0-next.10",
|
|
39
|
+
"@spexcode/session-selflaunch": "0.7.0-next.10",
|
|
40
|
+
"@spexcode/spec-core": "0.7.0-next.10",
|
|
41
|
+
"@spexcode/spec-eval": "0.7.0-next.10",
|
|
42
|
+
"@spexcode/spec-forge": "0.7.0-next.10",
|
|
43
|
+
"@spexcode/transcript": "0.7.0-next.10",
|
|
44
44
|
"smol-toml": "^1.8.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
@@ -19,9 +19,15 @@ Land the current SpexCode session's branch; do not dispatch another merge reques
|
|
|
19
19
|
3. Immediately before landing, verify
|
|
20
20
|
`git merge-base --is-ancestor <source-head> <session-head>`. If it fails, sync again. A clean textual merge
|
|
21
21
|
is not product proof.
|
|
22
|
-
4.
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
4. Land with one `--no-ff` merge of the already-synced session tip, without touching the source checkout's
|
|
23
|
+
dirty work. Git refuses a merge over a dirty index, so do NOT clear it: add a temporary detached worktree of
|
|
24
|
+
the source head (`git worktree add --detach <tmp> <source-branch>`), make the `--no-ff` merge there, then
|
|
25
|
+
fast-forward the source checkout to that commit (`git merge --ff-only <tmp-head>`) and remove the temporary
|
|
26
|
+
worktree. A fast-forward never rewrites a path the merge did not change, so user-owned dirty files keep
|
|
27
|
+
their bytes and their staged/unstaged state. Never `git restore`, `reset`, `checkout`, `stash`, or overwrite
|
|
28
|
+
a user-owned path — an unstaged edit has no blob anywhere, so "save and put back" loses it, and a copy of the
|
|
29
|
+
same path from another worktree is a different version, not a restoration. Do not resolve conflicts in the
|
|
30
|
+
source checkout; if the merge genuinely overlaps a user-owned path, stop and report the exact overlap.
|
|
25
31
|
5. Verify the source checkout has no `MERGE_HEAD`, the session tip is its ancestor, unrelated dirty
|
|
26
32
|
fingerprints are unchanged, and the post-merge gates pass. Push the source-of-truth branch only after
|
|
27
33
|
those checks.
|