@sporhq/spor 0.7.1 → 0.9.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/.claude-plugin/plugin.json +1 -1
- package/API.md +23 -6
- package/bin/spor.js +220 -62
- package/lib/graph.js +13 -0
- package/lib/kernel/graph.js +233 -2
- package/lib/kernel/queue.js +200 -60
- package/lib/queue.js +1 -1
- package/lib/remote.js +6 -3
- package/package.json +1 -1
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"name": "spor",
|
|
3
3
|
"displayName": "Spor Context Compiler",
|
|
4
4
|
"description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.9.0",
|
|
6
6
|
"author": { "name": "losthammer" }
|
|
7
7
|
}
|
package/API.md
CHANGED
|
@@ -22,7 +22,11 @@ graph's git repo. What a client sees:
|
|
|
22
22
|
and `session: <id>` and uses `authored_via: dispatch`, while `author:` stays
|
|
23
23
|
the agent's **owning person** — so the node reads "agent on behalf of person".
|
|
24
24
|
These ride-along fields are token-derived too; any supplied in the payload are
|
|
25
|
-
discarded.
|
|
25
|
+
discarded. The `session` is the agent's REAL run session: `spor dispatch` mints
|
|
26
|
+
the token **session-deferred** (it can't know the session before `claude --bg`
|
|
27
|
+
self-allocates it) and binds the real one post-launch via `POST /v1/agents/session`
|
|
28
|
+
(§3, dec-spor-dispatch-bg-session-late-bind) — so writes BEFORE the bind carry no
|
|
29
|
+
`session` (honest, never a phantom), and writes after trace to the actual run.
|
|
26
30
|
- **Create**: `if_exists: "skip"` → id collision is reported as `skipped`
|
|
27
31
|
(the distiller default); `if_exists: "error"` → id collision is a
|
|
28
32
|
`conflict` error.
|
|
@@ -301,8 +305,8 @@ endpoint is the REST twin of a core call:
|
|
|
301
305
|
| `POST /v1/nodes` | drain-outbox, mechanical writers | `put_node` semantics, batch: `{nodes: [...], if_exists: "skip"}` (entries may be raw strings or `{node, if_exists, revision}`) → `{results: [...]}`, 207 when any entry failed |
|
|
302
306
|
| `POST /v1/nodes/{id}/edges` `{type, to}` | scripts, mechanical writers | `add_edge` semantics (§1): normalize/flip, dedupe, append — no revision echo |
|
|
303
307
|
| `POST /v1/nodes/{id}/status` `{status}` | scripts, mechanical writers | `set_status` semantics (§1): one-scalar update through the `transitions()` gate. Setting a work node to an in-progress status also CLAIMS it (same lease as `/claim` below) |
|
|
304
|
-
| `POST /v1/nodes/{id}/claim` `{session?}` | `claim`/`set_status` MCP tools, `spor dispatch` | take the heartbeat-renewed lease (dec-cc-task-claim-lease): writes the durable `assigned` edge once, attributes to `$viewer` from the token (never an argument), and creates the ephemeral lease → `{ok, status, lease: {node_id, by, expires, expires_at, session, claimed_at}, edge}`. A live lease held by ANOTHER person is `409 conflict` naming the holder + expiry (re-claiming your OWN live claim just renews it). `session` scopes the heartbeat (omit to leave it person-scoped, so any of the claimer's sessions may renew — what `spor dispatch` does, since the
|
|
305
|
-
| `POST /v1/nodes/{id}/renew` `{session?}` | post-tool heartbeat, `renew` MCP tool | bump the live lease's expiry only — no commit; the heartbeat that keeps a claim from lapsing. A lapsed/stolen lease is `409` (names the current holder). Person-scoped: any of the claimer's sessions may renew |
|
|
308
|
+
| `POST /v1/nodes/{id}/claim` `{session?}` | `claim`/`set_status` MCP tools, `spor dispatch` | take the heartbeat-renewed lease (dec-cc-task-claim-lease): writes the durable `assigned` edge once, attributes to `$viewer` from the token (never an argument), and creates the ephemeral lease → `{ok, status, lease: {node_id, by, expires, expires_at, session, claimed_at}, edge}`. A live lease held by ANOTHER person is `409 conflict` naming the holder + expiry (re-claiming your OWN live claim just renews it). `session` scopes the heartbeat (omit to leave it person-scoped, so any of the claimer's sessions may renew — what `spor dispatch` does at the PRE-launch claim, since `claude --bg` self-allocates the run session only at launch; dispatch then renews with the real session once it has read it from `claude agents --json`, dec-spor-dispatch-bg-session-late-bind) |
|
|
309
|
+
| `POST /v1/nodes/{id}/renew` `{session?}` | post-tool heartbeat, `renew` MCP tool, `spor dispatch` | bump the live lease's expiry only — no commit; the heartbeat that keeps a claim from lapsing. A lapsed/stolen lease is `409` (names the current holder). Person-scoped: any of the claimer's sessions may renew; a `session` binds the lease to that run (`spor dispatch` uses this to bind the captured `claude --bg` session post-launch) |
|
|
306
310
|
| `POST /v1/nodes/{id}/release` | `release` MCP tool | drop the lease AND retire the durable `assigned` edge, returning the node to the pool. Idempotent (releasing a node you hold no lease on still succeeds, cleaning up any lingering `assigned` edge of yours); releasing a claim someone else holds is `409` naming the holder |
|
|
307
311
|
| `POST /v1/nodes/{id}/commits` `{repo, sha}` | post-tool / link-commits | `link_commit`: append `repo@sha` to the node's `commits:` list (kebab-case repo slug, 7–40 lowercase hex, ≤40 commits per node); idempotent, prefix-aware dedup |
|
|
308
312
|
| `GET /v1/commits/{sha}?repo=` | sessions doing git archaeology | sha → nodes lookup over the `commits:` fields (≥7 hex, abbreviated or full); each match carries `{repo, sha, id, type, title, summary, status, project}` — blame a line, get the why |
|
|
@@ -321,7 +325,8 @@ endpoint is the REST twin of a core call:
|
|
|
321
325
|
| `DELETE /v1/admin/tokens/{hash-prefix}` | offboarding / rotation | revoke the single PAT matching the hash prefix (≥8 hex chars; an ambiguous prefix is a 409) → `{revoked, hash_prefix}`. Admin-only |
|
|
322
326
|
| `GET /v1/agents` | `spor agent list` | list the agents the caller **owns** → `{agents: [{id, label, owner, spiffe, pubkey, status}], count}`; `?all=1` lists every agent (admin-only) |
|
|
323
327
|
| `POST /v1/admin/agents` `{label, owner?, id?, pubkey?}` | `spor agent create`, onboarding | create a person-owned `agent` node + its `owned-by` edge (`owner` defaults to the caller's person; `id` derives from `label`) → 201 `{id, owner, spiffe, pubkey, status, revision}`. 409 dup id / 422 invalid / 403 non-admin. Admin-only, same `stewards→root` gate as `/v1/admin/tokens` |
|
|
324
|
-
| `POST /v1/agents/{id}/token` `{session
|
|
328
|
+
| `POST /v1/agents/{id}/token` `{session?, audience?, expires?}` | `spor dispatch` | **self-serve** (NOT admin): mint a short-TTL, per-session token scoped to agent `{id}` → 201 `{token, expires_at, agent, session}` (`session: null` when deferred). Authorized iff the caller's person **owns** the agent (its `owned-by` edge) — else `403`; `404` unknown agent; `422` a SUPPLIED `session` that is malformed. `session` is now **OPTIONAL** (deferred binding, dec-spor-dispatch-bg-session-late-bind): `spor dispatch` mints it deferred because `claude --bg` self-allocates the run session only at launch, then binds the real one via `POST /v1/agents/session` below. The token carries `{agent, session?}` (the person is derived from the `owned-by` edge at verify time); a write under it is stamped agent-on-behalf-of-person (§1). A caller `expires` may only shorten the default TTL, never extend it |
|
|
329
|
+
| `POST /v1/agents/session` `{session}` | `spor dispatch` (post-launch) | **late session binding** for a session-deferred agent token (dec-spor-dispatch-bg-session-late-bind). Authenticated by the **agent token itself** (the bearer hash identifies its own record — no agent id in the path, no ownership re-check), so only an agent-scoped token may call it (`403` otherwise). Sets that token's `session` → `200 {ok, agent, session}`. **Write-once**: idempotent on the same value (`{unchanged: true}`), `409 conflict` on a different one (a token's session is provenance, not a mutable field); `422` missing/malformed `session`. Every subsequent write under the token then stamps the bound session |
|
|
325
330
|
|
|
326
331
|
Path parameters (node ids, project slugs) must match
|
|
327
332
|
`^[a-z0-9][a-z0-9-]*$`. Request bodies are capped at 1MB
|
|
@@ -378,8 +383,20 @@ anything with a token.
|
|
|
378
383
|
background agent (so the agent's own graph writes carry its identity), picking
|
|
379
384
|
the machine's default agent from the `dispatch.agent` client config (set with
|
|
380
385
|
`spor agent use <agent-id>`, or `SPOR_DISPATCH_AGENT`), which `spor dispatch
|
|
381
|
-
--as <agent-id>` overrides for a single run.
|
|
382
|
-
|
|
386
|
+
--as <agent-id>` overrides for a single run. The `<agent-id>` is the agent's
|
|
387
|
+
`agent-`-prefixed NODE id (what `spor agent list` prints), **not** its bare
|
|
388
|
+
label — the token endpoint requires the prefix, so the client setters
|
|
389
|
+
(`spor agent use`, `--as`) reject a prefix-less id with a `did you mean
|
|
390
|
+
agent-…?` hint rather than persist one every dispatch would 422 on. (Not to be
|
|
391
|
+
confused with `spor dispatch --agent`, the unrelated `claude --agent` harness
|
|
392
|
+
passthrough.) The
|
|
393
|
+
token is minted **session-deferred** and bound to the real run session AFTER
|
|
394
|
+
launch (dec-spor-dispatch-bg-session-late-bind): `claude --bg` ignores
|
|
395
|
+
`--session-id` and self-allocates its session, so dispatch reads the real one
|
|
396
|
+
from `claude agents --json` and binds it via `POST /v1/agents/session` (§3) — the
|
|
397
|
+
one place an agent token's session is set, write-once. The session can't be
|
|
398
|
+
forged a-priori (it isn't known until the run exists) and can't ride the write
|
|
399
|
+
payload (token-derived, §1), so the binding is always the actual run.
|
|
383
400
|
- **OAuth 2.1 for MCP connectors** (Cowork/claude.ai, which cannot carry a
|
|
384
401
|
static bearer token): protected-resource metadata discovery (RFC 9728,
|
|
385
402
|
advertised on the `/mcp` 401 via `WWW-Authenticate`), authorization-server
|
package/bin/spor.js
CHANGED
|
@@ -418,7 +418,9 @@ function dispatchedAgents() {
|
|
|
418
418
|
if (!a || a.kind !== "background" || typeof a.name !== "string") continue;
|
|
419
419
|
if (a.state === "done") continue; // finished — not in flight
|
|
420
420
|
const list = map.get(a.name) || [];
|
|
421
|
-
|
|
421
|
+
// sessionId + startedAt ride along for post-launch session capture
|
|
422
|
+
// (dec-spor-dispatch-bg-session-late-bind); the dup-guard ignores them.
|
|
423
|
+
list.push({ id: a.id, name: a.name, state: a.state, status: a.status, cwd: a.cwd, sessionId: a.sessionId, startedAt: a.startedAt });
|
|
422
424
|
map.set(a.name, list);
|
|
423
425
|
}
|
|
424
426
|
return map;
|
|
@@ -427,6 +429,40 @@ function dispatchedAgents() {
|
|
|
427
429
|
}
|
|
428
430
|
}
|
|
429
431
|
|
|
432
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
433
|
+
|
|
434
|
+
// Find the FULL run-session id of the agent `spor dispatch` just launched in `dir`
|
|
435
|
+
// (dec-spor-dispatch-bg-session-late-bind). `claude --bg` self-allocates and
|
|
436
|
+
// prints only a SHORT id, but `claude agents --json` reports the full `sessionId`
|
|
437
|
+
// + `cwd` + `startedAt` — the reliable capture path. Match on cwd (the strong
|
|
438
|
+
// signal — we just launched there), then on name when given, then pick the NEWEST
|
|
439
|
+
// (the run we just started). Returns the sessionId or null.
|
|
440
|
+
function newestDispatchedSession(name, dir) {
|
|
441
|
+
const all = [];
|
|
442
|
+
for (const arr of dispatchedAgents().values()) for (const a of arr) all.push(a);
|
|
443
|
+
let cands = all.filter((a) => a.sessionId && (!dir || a.cwd === dir));
|
|
444
|
+
if (name) {
|
|
445
|
+
const named = cands.filter((a) => a.name === name);
|
|
446
|
+
if (named.length) cands = named;
|
|
447
|
+
}
|
|
448
|
+
if (!cands.length) return null;
|
|
449
|
+
cands.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0));
|
|
450
|
+
return cands[0].sessionId;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Capture the launched run's session, polling briefly while the daemon registers
|
|
454
|
+
// it. SPOR_SESSION_ID pins it (tests/reproducibility) and short-circuits the poll.
|
|
455
|
+
// Returns the sessionId or null (fail-open — the caller degrades to session-null).
|
|
456
|
+
async function captureDispatchSession(name, dir, pinned) {
|
|
457
|
+
if (pinned) return pinned;
|
|
458
|
+
for (let i = 0; i < 6; i++) {
|
|
459
|
+
const sid = newestDispatchedSession(name, dir);
|
|
460
|
+
if (sid) return sid;
|
|
461
|
+
await sleep(300);
|
|
462
|
+
}
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
|
|
430
466
|
// Stamp items[].in_flight from the dispatched-agent map, optionally dropping the
|
|
431
467
|
// in-flight ones (--hide-dispatched). Every kept item gets an in_flight boolean
|
|
432
468
|
// (so the flag is present on all of them — claude absent => uniformly false);
|
|
@@ -984,6 +1020,28 @@ async function cmdAgent(cfg, args) {
|
|
|
984
1020
|
return 1;
|
|
985
1021
|
}
|
|
986
1022
|
|
|
1023
|
+
// A valid Spor agent id, mirroring the server's token-mint contract EXACTLY
|
|
1024
|
+
// (spor-server server/rest.js: `SLUG_RE.test(id) && id.startsWith("agent-")`).
|
|
1025
|
+
// The `agent-` prefix is load-bearing: the agent NODE id carries it, but the
|
|
1026
|
+
// `spor agent create`/`list` output also prints the bare LABEL, so copying the
|
|
1027
|
+
// label into `spor agent use`/`dispatch --as` is an easy slip — and the bare
|
|
1028
|
+
// slug passes a plain kebab check while the server's POST /v1/agents/{id}/token
|
|
1029
|
+
// 422s on it (invalid_node), silently dropping the dispatch to person-scoped.
|
|
1030
|
+
// One predicate, used by every client setter that feeds that endpoint, so the
|
|
1031
|
+
// client never accepts an id the server rejects
|
|
1032
|
+
// (issue-spor-dispatch-agent-id-prefix-validation-gap).
|
|
1033
|
+
function isAgentId(id) {
|
|
1034
|
+
return typeof id === "string" && /^[a-z0-9][a-z0-9-]*$/.test(id) && id.startsWith("agent-");
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// When a rejected id is a valid kebab slug that merely DROPPED the `agent-`
|
|
1038
|
+
// prefix (the common label-vs-id slip), suggest the prefixed form; else null.
|
|
1039
|
+
function agentIdGuess(id) {
|
|
1040
|
+
return typeof id === "string" && /^[a-z0-9][a-z0-9-]*$/.test(id) && !id.startsWith("agent-")
|
|
1041
|
+
? `agent-${id}`
|
|
1042
|
+
: null;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
987
1045
|
// `spor agent use <agent-id>` — make this agent the machine's default dispatch
|
|
988
1046
|
// identity by writing `dispatch.agent` to the USER config.json (the same
|
|
989
1047
|
// machine-local, never-committed file as the repo map; per-machine, like
|
|
@@ -997,8 +1055,10 @@ function cmdAgentUse(cfg, { id }) {
|
|
|
997
1055
|
err("usage: spor agent use <agent-id> (or: spor agent use --clear)");
|
|
998
1056
|
return 1;
|
|
999
1057
|
}
|
|
1000
|
-
if (!clear &&
|
|
1001
|
-
err(`invalid agent id '${id}' — must
|
|
1058
|
+
if (!clear && !isAgentId(id)) {
|
|
1059
|
+
err(`invalid agent id '${id}' — must be an 'agent-<slug>' kebab id (e.g. agent-your-machine)`);
|
|
1060
|
+
const guess = agentIdGuess(id);
|
|
1061
|
+
if (guess) err(` did you mean '${guess}'? ('spor agent list' shows the full id — the 'agent-' prefix is part of it, not the label)`);
|
|
1002
1062
|
return 1;
|
|
1003
1063
|
}
|
|
1004
1064
|
const home = cfg.userConfigHome();
|
|
@@ -2056,17 +2116,16 @@ async function topQueueItem(cfg, slug) {
|
|
|
2056
2116
|
// post-tool heartbeat drives (dec-cc-task-claim-lease, task-cc-claim-nudge-hook).
|
|
2057
2117
|
// REMOTE-MODE ONLY: a claim is a server-held lease; local mode has no pool or
|
|
2058
2118
|
// contention (dec-cc-task-claim-lease "Local mode"), so the caller skips it and
|
|
2059
|
-
// local dispatch stays byte-identical.
|
|
2060
|
-
// (dec-spor-session-
|
|
2061
|
-
//
|
|
2062
|
-
//
|
|
2063
|
-
//
|
|
2064
|
-
//
|
|
2065
|
-
//
|
|
2066
|
-
//
|
|
2067
|
-
//
|
|
2068
|
-
//
|
|
2069
|
-
// dispatches anyway).
|
|
2119
|
+
// local dispatch stays byte-identical. PRE-LAUNCH the claim is PERSON-SCOPED
|
|
2120
|
+
// (session omitted, dec-spor-dispatch-bg-session-late-bind): `claude --bg`
|
|
2121
|
+
// IGNORES `--session-id` and self-allocates its real session, so the working
|
|
2122
|
+
// session is NOT knowable up front — binding the lease to a forced uuid was a
|
|
2123
|
+
// phantom (issue-spor-dispatch-bg-ignores-forced-session-id). Dispatch instead
|
|
2124
|
+
// captures the real session post-launch and binds it via renewDispatch (and the
|
|
2125
|
+
// bg agent's own post-tool heartbeat renews the same-session lease thereafter).
|
|
2126
|
+
// Returns {ok} on success/idempotent-renew, {conflict, message} when the node is
|
|
2127
|
+
// already held (the concurrent-dispatch case this guards), or {error} for any
|
|
2128
|
+
// other failure (fail-open: the caller warns and dispatches anyway).
|
|
2070
2129
|
async function claimDispatch(cfg, nodeId, session) {
|
|
2071
2130
|
const body = session ? { session } : {};
|
|
2072
2131
|
const r = await remote.post(cfg, `/v1/nodes/${encodeURIComponent(nodeId)}/claim`, body, { timeoutMs: 6000 });
|
|
@@ -2088,6 +2147,35 @@ async function claimDispatch(cfg, nodeId, session) {
|
|
|
2088
2147
|
return { ok: false, error: r.transport ? r.error : `HTTP ${r.status}${code ? ` (${code})` : ""}` };
|
|
2089
2148
|
}
|
|
2090
2149
|
|
|
2150
|
+
// Renew the dispatch lease, binding it to the REAL session captured post-launch
|
|
2151
|
+
// (dec-spor-dispatch-bg-session-late-bind). The pre-launch claim was person-scoped;
|
|
2152
|
+
// this binds the lease's session to the real `claude --bg` run so the lease and the
|
|
2153
|
+
// rebound agent token agree from the start (instead of waiting for the agent's first
|
|
2154
|
+
// heartbeat to self-heal it). Best-effort: a lapsed/stolen lease (409) or any other
|
|
2155
|
+
// failure is swallowed — the bg agent's heartbeat still renews it. Returns {ok}.
|
|
2156
|
+
async function renewDispatch(cfg, nodeId, session) {
|
|
2157
|
+
const r = await remote.post(cfg, `/v1/nodes/${encodeURIComponent(nodeId)}/renew`, { session }, { timeoutMs: 3000 });
|
|
2158
|
+
return { ok: !!r.ok };
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// Late-bind the agent token's run session (dec-spor-dispatch-bg-session-late-bind).
|
|
2162
|
+
// The token was minted session-DEFERRED before launch (the session wasn't knowable
|
|
2163
|
+
// yet); this reports the REAL session captured from `claude agents --json`,
|
|
2164
|
+
// authenticated by the AGENT TOKEN ITSELF (not the person token) so the server can
|
|
2165
|
+
// set it on that token's record. Every subsequent write under the token then stamps
|
|
2166
|
+
// the real session. Best-effort/fail-open: a server without the route (404), a
|
|
2167
|
+
// conflict (409), or any transport error leaves the token session-null (writes carry
|
|
2168
|
+
// no session — honest, never a phantom) rather than blocking dispatch. Returns
|
|
2169
|
+
// {ok}|{absent}|{conflict}|{error}.
|
|
2170
|
+
async function bindAgentSession(cfg, agentToken, session) {
|
|
2171
|
+
const r = await remote.post(cfg, `/v1/agents/session`, { session }, { timeoutMs: 3000, token: agentToken });
|
|
2172
|
+
if (r.ok) return { ok: true };
|
|
2173
|
+
if (r.status === 404) return { ok: false, absent: true };
|
|
2174
|
+
if (r.status === 409) return { ok: false, conflict: true };
|
|
2175
|
+
const code = r.json && r.json.error && r.json.error.code;
|
|
2176
|
+
return { ok: false, error: r.transport ? r.error : `HTTP ${r.status}${code ? ` (${code})` : ""}` };
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2091
2179
|
// Resolve the directory to launch in. --dir wins; else a known slug is looked up
|
|
2092
2180
|
// in the map; else the cwd's durable repo root. { dir:null } means "slug unknown
|
|
2093
2181
|
// here". The cwd fallback uses dispatchRoot() (not repoRoot()) so a dispatch run
|
|
@@ -2190,13 +2278,18 @@ function dispatchAgentId(cfg) {
|
|
|
2190
2278
|
// OWNERSHIP-gated, NOT admin-gated: POST /v1/agents/{id}/token authenticated with
|
|
2191
2279
|
// the dispatcher's normal person token (SPOR_TOKEN); the server checks the caller
|
|
2192
2280
|
// OWNS agent {id} (the owned-by edge) — so a normal teammate can mint a token for
|
|
2193
|
-
// their own machine's agent without being an admin. REMOTE only.
|
|
2194
|
-
//
|
|
2195
|
-
//
|
|
2196
|
-
//
|
|
2197
|
-
//
|
|
2281
|
+
// their own machine's agent without being an admin. REMOTE only. The token is
|
|
2282
|
+
// minted session-DEFERRED (session omitted) when the real session isn't yet known
|
|
2283
|
+
// — the standing case, since `claude --bg` allocates it only at launch
|
|
2284
|
+
// (dec-spor-dispatch-bg-session-late-bind); dispatch binds the real session
|
|
2285
|
+
// afterward via bindAgentSession. The `session` param is kept for a caller that
|
|
2286
|
+
// genuinely knows it up front (none today — dispatch always defers). Returns
|
|
2287
|
+
// { ok, token } on success, { absent:true }
|
|
2288
|
+
// when the mint surface isn't deployed yet (404 — fail soft, dispatch person-
|
|
2289
|
+
// scoped), or { error } on any other failure incl. 403/owner-mismatch (also fail
|
|
2290
|
+
// soft — warn and dispatch person-scoped, never block).
|
|
2198
2291
|
async function mintAgentToken(cfg, { agent, session }) {
|
|
2199
|
-
const r = await remote.post(cfg, `/v1/agents/${encodeURIComponent(agent)}/token`, { session }, { timeoutMs: 6000 });
|
|
2292
|
+
const r = await remote.post(cfg, `/v1/agents/${encodeURIComponent(agent)}/token`, session ? { session } : {}, { timeoutMs: 6000 });
|
|
2200
2293
|
if (r.transport) return { error: r.error };
|
|
2201
2294
|
// 404 = no route (surface not deployed) => absent, dispatch falls back cleanly.
|
|
2202
2295
|
if (r.status === 404) return { absent: true };
|
|
@@ -2209,16 +2302,17 @@ async function mintAgentToken(cfg, { agent, session }) {
|
|
|
2209
2302
|
// Write the 0600 --mcp-config JSON that gives the bg agent ONLY its own
|
|
2210
2303
|
// agent-scoped Spor MCP (account connector excluded by --strict-mcp-config,
|
|
2211
2304
|
// verified #1). Machine-local, gitignored-adjacent path under the user config
|
|
2212
|
-
// home's outbox; per-
|
|
2213
|
-
//
|
|
2214
|
-
//
|
|
2215
|
-
//
|
|
2216
|
-
//
|
|
2217
|
-
|
|
2305
|
+
// home's outbox; per-dispatch filename (`key`, a fresh uuid) so concurrent
|
|
2306
|
+
// dispatches don't collide — the session id is no longer known at this point
|
|
2307
|
+
// (deferred until post-launch, dec-spor-dispatch-bg-session-late-bind). Returns
|
|
2308
|
+
// the file path. The bg agent reads it on startup AFTER this process exits (claude
|
|
2309
|
+
// --bg detaches), so we cannot delete it eagerly — cleanup is a best-effort sweep
|
|
2310
|
+
// of stale files here, plus the documented short-TTL token inside it.
|
|
2311
|
+
function writeDispatchMcpConfig(cfg, { token, key }) {
|
|
2218
2312
|
const dir = path.join(cfg.userConfigHome(), "outbox", "dispatch");
|
|
2219
2313
|
fs.mkdirSync(dir, { recursive: true });
|
|
2220
2314
|
sweepStaleMcpConfigs(dir);
|
|
2221
|
-
const file = path.join(dir, `mcp-${
|
|
2315
|
+
const file = path.join(dir, `mcp-${key}.json`);
|
|
2222
2316
|
const conf = {
|
|
2223
2317
|
mcpServers: {
|
|
2224
2318
|
spor: {
|
|
@@ -2231,8 +2325,8 @@ function writeDispatchMcpConfig(cfg, { token, session }) {
|
|
|
2231
2325
|
// 0600: the file holds a live bearer token. Create with O_EXCL (wx) so a
|
|
2232
2326
|
// pre-placed file or symlink at this path is REFUSED rather than written
|
|
2233
2327
|
// through, and the file is 0600 from creation (no widen-then-narrow window).
|
|
2234
|
-
// The
|
|
2235
|
-
//
|
|
2328
|
+
// The uuid filename makes a real collision a non-issue; a stale leftover was
|
|
2329
|
+
// swept above.
|
|
2236
2330
|
const fd = fs.openSync(file, "wx", 0o600);
|
|
2237
2331
|
try {
|
|
2238
2332
|
fs.writeSync(fd, JSON.stringify(conf, null, 2) + "\n");
|
|
@@ -2396,23 +2490,45 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
|
2396
2490
|
// exit / unparseable output => empty => no guard (fail-open); --force overrides.
|
|
2397
2491
|
const inFlight = nodeId && !backfill ? dispatchedAgents().get(name) || [] : [];
|
|
2398
2492
|
|
|
2399
|
-
// Session identity (dec-spor-session-
|
|
2400
|
-
// session
|
|
2401
|
-
//
|
|
2402
|
-
//
|
|
2403
|
-
//
|
|
2404
|
-
|
|
2493
|
+
// Session identity (dec-spor-dispatch-bg-session-late-bind). `claude --bg`
|
|
2494
|
+
// IGNORES `--session-id` and self-allocates its own run session (verified — it
|
|
2495
|
+
// warns and ignores the flag), so we do NOT force one and the session is NOT
|
|
2496
|
+
// knowable up front. The agent token is minted session-DEFERRED; the real
|
|
2497
|
+
// session is captured from `claude agents --json` AFTER launch and bound then
|
|
2498
|
+
// (rebind the token + renew the lease). SPOR_SESSION_ID pins the session for
|
|
2499
|
+
// tests/reproducibility (short-circuits the capture). `mcpKey` names the 0600
|
|
2500
|
+
// --mcp-config file — a fresh uuid, since the session id isn't available here.
|
|
2501
|
+
const pinnedSession = process.env.SPOR_SESSION_ID || null;
|
|
2502
|
+
const mcpKey = crypto.randomUUID();
|
|
2405
2503
|
// This machine's agent node — the WHO a dispatched session runs as. `--as`
|
|
2406
|
-
// overrides the per-machine dispatch.agent default for this one dispatch
|
|
2407
|
-
// id
|
|
2408
|
-
//
|
|
2409
|
-
//
|
|
2410
|
-
//
|
|
2411
|
-
|
|
2412
|
-
|
|
2504
|
+
// overrides the per-machine dispatch.agent default for this one dispatch. The
|
|
2505
|
+
// id must satisfy the SAME contract the server's token-mint endpoint enforces
|
|
2506
|
+
// (an 'agent-<slug>' kebab id) — an EXPLICIT --as that doesn't is a hard error
|
|
2507
|
+
// here, caught before any side effect rather than as a per-dispatch 422
|
|
2508
|
+
// (issue-spor-dispatch-agent-id-prefix-validation-gap). Only meaningful remotely
|
|
2509
|
+
// (the server is the CA that mints the agent token); a local-mode dispatch or an
|
|
2510
|
+
// unconfigured machine simply runs person-scoped.
|
|
2511
|
+
if (asAgent && !isAgentId(asAgent)) {
|
|
2512
|
+
err(`invalid --as agent id '${asAgent}' — must be an 'agent-<slug>' kebab id (e.g. agent-your-machine)`);
|
|
2513
|
+
const guess = agentIdGuess(asAgent);
|
|
2514
|
+
if (guess) err(` did you mean '--as ${guess}'? ('spor agent list' shows the full id — the 'agent-' prefix is part of it, not the label)`);
|
|
2413
2515
|
return 1;
|
|
2414
2516
|
}
|
|
2415
|
-
|
|
2517
|
+
let identityAgent = cfg.mode() === "remote" ? (asAgent || dispatchAgentId(cfg)) : null;
|
|
2518
|
+
// A configured `dispatch.agent` (no --as) that isn't a valid agent id — e.g. the
|
|
2519
|
+
// agent's LABEL stored instead of its 'agent-'-prefixed NODE id — would 422 at
|
|
2520
|
+
// token-mint and silently fall back to person-scoped on EVERY dispatch, quietly
|
|
2521
|
+
// defeating agent attribution (issue-spor-dispatch-agent-id-prefix-validation-gap).
|
|
2522
|
+
// Catch it here with an actionable line and run person-scoped, rather than a
|
|
2523
|
+
// round-trip to a 422 that names nothing. Fail-soft (don't block the dispatch):
|
|
2524
|
+
// the explicit --as path already hard-errored above, so this only fires for the
|
|
2525
|
+
// config default.
|
|
2526
|
+
if (identityAgent && !isAgentId(identityAgent)) {
|
|
2527
|
+
err(`warning: configured dispatch.agent '${identityAgent}' is not a valid agent id — dispatching person-scoped.`);
|
|
2528
|
+
const guess = agentIdGuess(identityAgent);
|
|
2529
|
+
err(` agent ids start with 'agent-'.${guess ? ` fix: spor agent use ${guess}` : ""} ('spor agent list' shows your agents.)`);
|
|
2530
|
+
identityAgent = null;
|
|
2531
|
+
}
|
|
2416
2532
|
// An explicit --as can't take effect in local mode — there is no CA to mint the
|
|
2417
2533
|
// agent token. Say so rather than silently dropping it to person-scoped.
|
|
2418
2534
|
if (asAgent && cfg.mode() !== "remote") {
|
|
@@ -2425,9 +2541,8 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
|
2425
2541
|
if (model) claudeArgs.push("--model", model);
|
|
2426
2542
|
if (permMode) claudeArgs.push("--permission-mode", permMode);
|
|
2427
2543
|
if (agent) claudeArgs.push("--agent", agent);
|
|
2428
|
-
//
|
|
2429
|
-
//
|
|
2430
|
-
claudeArgs.push("--session-id", session);
|
|
2544
|
+
// NB: no `--session-id` — `claude --bg` ignores it (warns) and manages its own
|
|
2545
|
+
// session; we capture the real one post-launch (dec-spor-dispatch-bg-session-late-bind).
|
|
2431
2546
|
|
|
2432
2547
|
if (dryRun) {
|
|
2433
2548
|
out(`dir: ${res.dir} (slug: ${res.slug}, via ${res.source})`);
|
|
@@ -2439,7 +2554,7 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
|
2439
2554
|
out(`onboard: ${steps.join("; ")}`);
|
|
2440
2555
|
}
|
|
2441
2556
|
out(`brief: ${brief ? `${brief.length} bytes` : "(none — graph had nothing relevant, or --no-brief/--backfill)"}`);
|
|
2442
|
-
out(`session: ${
|
|
2557
|
+
out(`session: ${pinnedSession || "(allocated by claude --bg at launch, bound after)"}`);
|
|
2443
2558
|
// Identity preview: what the real dispatch would do for agent-scoping. The
|
|
2444
2559
|
// token mint + 0600 mcp-config are SIDE EFFECTS, so --print only describes
|
|
2445
2560
|
// them (it writes nothing and makes no network call here). Local mode and an
|
|
@@ -2447,7 +2562,7 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
|
2447
2562
|
// session line, which is additive and always present now.
|
|
2448
2563
|
if (identityAgent) {
|
|
2449
2564
|
const src = asAgent ? " (via --as)" : "";
|
|
2450
|
-
out(`agent: ${identityAgent}${src} (would mint a
|
|
2565
|
+
out(`agent: ${identityAgent}${src} (would mint a session-deferred agent-scoped token + write a 0600 --mcp-config, add --strict-mcp-config, then bind the run session after launch)`);
|
|
2451
2566
|
} else if (cfg.mode() === "remote") {
|
|
2452
2567
|
out(`agent: (none configured — 'spor agent use agent-<machine>' or --as to attribute as agent-on-behalf-of; dispatching person-scoped)`);
|
|
2453
2568
|
}
|
|
@@ -2463,7 +2578,7 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
|
2463
2578
|
// Auto-claim preview (remote node dispatch only — local mode has no lease, so
|
|
2464
2579
|
// nothing is announced there and local --print stays byte-identical).
|
|
2465
2580
|
if (nodeId && !backfill && cfg.mode() === "remote") {
|
|
2466
|
-
out(`claim: ${noClaim ? "(--no-claim — lease not established)" : `would establish a
|
|
2581
|
+
out(`claim: ${noClaim ? "(--no-claim — lease not established)" : `would establish a lease on ${nodeId} at launch (session bound from the run after launch)`}`);
|
|
2467
2582
|
}
|
|
2468
2583
|
if (template != null) out(`template: ${path.resolve(templateOpt)}`);
|
|
2469
2584
|
out(`run: ${claudeBin} ${claudeArgs.map(shellQuote).join(" ")} <prompt>`);
|
|
@@ -2500,21 +2615,35 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
|
2500
2615
|
// the VERIFIED mechanism): mint a per-session agent-scoped token, write it into
|
|
2501
2616
|
// a 0600 --mcp-config that exposes ONLY the agent's own Spor MCP, and add
|
|
2502
2617
|
// --strict-mcp-config so the account connector is excluded by construction. The
|
|
2503
|
-
// server then stamps authored_by_agent + session from that token.
|
|
2618
|
+
// server then stamps authored_by_agent + session from that token. The token is
|
|
2619
|
+
// minted session-DEFERRED — the run session isn't known until `claude --bg`
|
|
2620
|
+
// self-allocates it, so we bind it AFTER launch (dec-spor-dispatch-bg-session-
|
|
2621
|
+
// late-bind), keeping `agentToken` to authenticate that late bind. FAIL SOFT at
|
|
2504
2622
|
// every step — a server without the mint surface, or a transient error, falls
|
|
2505
2623
|
// back to the prior person-scoped dispatch with a clear line. Remote + a
|
|
2506
|
-
// configured agent only; local/unconfigured dispatch is byte-identical
|
|
2507
|
-
|
|
2624
|
+
// configured agent only; local/unconfigured dispatch is byte-identical.
|
|
2625
|
+
let agentToken = null;
|
|
2508
2626
|
if (identityAgent) {
|
|
2509
|
-
|
|
2627
|
+
// Always session-DEFERRED — the run session is bound after launch (below),
|
|
2628
|
+
// even when SPOR_SESSION_ID pins it (the pin feeds the capture, not the mint),
|
|
2629
|
+
// so the bind path is uniform.
|
|
2630
|
+
const mint = await mintAgentToken(cfg, { agent: identityAgent });
|
|
2510
2631
|
if (mint.ok) {
|
|
2511
|
-
|
|
2632
|
+
agentToken = mint.token;
|
|
2633
|
+
const mcpFile = writeDispatchMcpConfig(cfg, { token: mint.token, key: mcpKey });
|
|
2512
2634
|
claudeArgs.push("--mcp-config", mcpFile, "--strict-mcp-config");
|
|
2513
|
-
out(`agent: ${identityAgent} (
|
|
2635
|
+
out(`agent: ${identityAgent} (writes attributed agent-on-behalf-of-you; run session bound after launch)`);
|
|
2514
2636
|
} else if (mint.absent) {
|
|
2515
|
-
err(`warning: this server can't mint agent-scoped session tokens yet — dispatching person-scoped
|
|
2637
|
+
err(`warning: this server can't mint agent-scoped session tokens yet — dispatching person-scoped.`);
|
|
2516
2638
|
} else {
|
|
2517
|
-
|
|
2639
|
+
// Name the offending agent and the fix — a bare "(HTTP 422 …)" tells the
|
|
2640
|
+
// operator nothing about WHICH id is wrong or how to repair it. The format
|
|
2641
|
+
// gate is now caught client-side above, so a 422 here means the id is a
|
|
2642
|
+
// well-formed 'agent-<slug>' the server still rejected (e.g. no such agent /
|
|
2643
|
+
// not owned); point at the list either way
|
|
2644
|
+
// (issue-spor-dispatch-agent-id-prefix-validation-gap).
|
|
2645
|
+
err(`warning: could not mint an agent token for ${identityAgent} (${mint.error}) — dispatching person-scoped.`);
|
|
2646
|
+
err(` check it exists and you own it: spor agent list (set this machine's default with: spor agent use <agent-id>)`);
|
|
2518
2647
|
}
|
|
2519
2648
|
}
|
|
2520
2649
|
|
|
@@ -2523,18 +2652,19 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
|
2523
2652
|
// duplicate agent onto contested work, and the lease is live the moment the
|
|
2524
2653
|
// agent starts (its post-tool writes then renew it — and seeing its own held
|
|
2525
2654
|
// claim, it skips the redundant claim-nudge). Remote node-mode only; --no-claim
|
|
2526
|
-
// opts out (dispatch with no lease, the prior behavior).
|
|
2527
|
-
// (dec-spor-session-
|
|
2528
|
-
//
|
|
2655
|
+
// opts out (dispatch with no lease, the prior behavior). PERSON-SCOPED here
|
|
2656
|
+
// (session omitted, dec-spor-dispatch-bg-session-late-bind): the real session
|
|
2657
|
+
// isn't known until after launch, so we bind it to the lease via renewDispatch
|
|
2658
|
+
// below; until then any of this person's sessions may renew it.
|
|
2529
2659
|
if (nodeId && !backfill && !noClaim && cfg.mode() === "remote") {
|
|
2530
|
-
const c = await claimDispatch(cfg, nodeId
|
|
2660
|
+
const c = await claimDispatch(cfg, nodeId);
|
|
2531
2661
|
if (c.conflict) {
|
|
2532
2662
|
err(`${nodeId} is already claimed — ${c.message}`);
|
|
2533
2663
|
err(` not dispatching a duplicate. Re-run with --no-claim to dispatch anyway (no lease),`);
|
|
2534
2664
|
err(` or pick another task with 'spor next'.`);
|
|
2535
2665
|
return 1;
|
|
2536
2666
|
}
|
|
2537
|
-
if (c.ok) out(`claimed ${nodeId} (
|
|
2667
|
+
if (c.ok) out(`claimed ${nodeId} (lease established; the agent's writes will renew it)`);
|
|
2538
2668
|
else err(`warning: could not establish a lease on ${nodeId}: ${c.error} — dispatching without a claim`);
|
|
2539
2669
|
}
|
|
2540
2670
|
claudeArgs.push(prompt);
|
|
@@ -2543,6 +2673,34 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
|
2543
2673
|
err(`could not launch ${claudeBin}: ${r.error.message}`);
|
|
2544
2674
|
return 1;
|
|
2545
2675
|
}
|
|
2676
|
+
|
|
2677
|
+
// Late session binding (dec-spor-dispatch-bg-session-late-bind). `claude --bg`
|
|
2678
|
+
// has now self-allocated its run session and registered the agent; read the
|
|
2679
|
+
// REAL session from `claude agents --json` and bind it: (a) rebind the agent
|
|
2680
|
+
// token's session so every subsequent agent write stamps the real run, and
|
|
2681
|
+
// (b) renew the lease to it so lease and token agree (instead of waiting for
|
|
2682
|
+
// the agent's first heartbeat to self-heal). Best-effort throughout — a capture
|
|
2683
|
+
// miss or any bind failure leaves the token session-null (writes carry no
|
|
2684
|
+
// session: honest, never a phantom) and the lease self-healing via heartbeat.
|
|
2685
|
+
// Remote only, and only when there's something to bind (an agent token and/or a
|
|
2686
|
+
// claimed node).
|
|
2687
|
+
const wantBind = cfg.mode() === "remote" && (agentToken || (nodeId && !backfill && !noClaim));
|
|
2688
|
+
if (wantBind) {
|
|
2689
|
+
const realSession = await captureDispatchSession(name, res.dir, pinnedSession);
|
|
2690
|
+
if (realSession) {
|
|
2691
|
+
if (agentToken) {
|
|
2692
|
+
const b = await bindAgentSession(cfg, agentToken, realSession);
|
|
2693
|
+
if (b.ok) out(`session: ${realSession} (bound — the agent's writes trace to this run)`);
|
|
2694
|
+
else if (b.conflict) err(`note: the agent token is already bound to another session — leaving it.`);
|
|
2695
|
+
// absent/transport error: token stays session-deferred (no phantom) — silent, fail-open.
|
|
2696
|
+
} else {
|
|
2697
|
+
out(`session: ${realSession}`);
|
|
2698
|
+
}
|
|
2699
|
+
if (nodeId && !backfill && !noClaim) await renewDispatch(cfg, nodeId, realSession);
|
|
2700
|
+
} else if (agentToken) {
|
|
2701
|
+
err(`note: could not read the run session from 'claude agents' — writes will carry no session stamp (the lease still self-heals).`);
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2546
2704
|
return r.status == null ? 1 : r.status;
|
|
2547
2705
|
}
|
|
2548
2706
|
|
package/lib/graph.js
CHANGED
|
@@ -42,6 +42,15 @@ function loadGraph(nodesDir) {
|
|
|
42
42
|
return kernel.buildGraph(shell.readGraphFiles(dir), { nodesDir: dir, seedSchemas: loadSeedSchemas() });
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// Incremental single-node cache update (SERVER.md §4.1, task-cc-spor-tier-2-scale):
|
|
46
|
+
// mutate a resident graph in place for one created/updated node instead of the
|
|
47
|
+
// O(corpus) loadGraph rebuild. Returns { id, mode } or { id, reloadRequired }.
|
|
48
|
+
// The kernel owns the tf-idf/adjacency representation, so the incremental twin
|
|
49
|
+
// of buildGraph lives beside it; the server reaches it through this seam.
|
|
50
|
+
function applyNode(graph, text, file) {
|
|
51
|
+
return kernel.applyNode(graph, text, file);
|
|
52
|
+
}
|
|
53
|
+
|
|
45
54
|
function compile(graph, opts = {}) {
|
|
46
55
|
return kernel.compile(graph, { ...opts, seedSchemas: opts.seedSchemas ?? loadSeedSchemas() });
|
|
47
56
|
}
|
|
@@ -80,6 +89,7 @@ const KNOWN_EDGES = seedRegistry().knownEdgeTypes();
|
|
|
80
89
|
|
|
81
90
|
module.exports = {
|
|
82
91
|
loadGraph,
|
|
92
|
+
applyNode,
|
|
83
93
|
compile,
|
|
84
94
|
validateNode: kernel.validateNode,
|
|
85
95
|
validateGraph,
|
|
@@ -108,6 +118,9 @@ module.exports = {
|
|
|
108
118
|
// decision queue (QUEUE.md §4/§5): re-exported so the server keeps one door.
|
|
109
119
|
// (Safe non-cycle: queue.js only requires graph.js inside its CLI block.)
|
|
110
120
|
rankQueue: require("./queue.js").rankQueue,
|
|
121
|
+
// warm the queue's memoized HEAD-pure indexes on a resident graph
|
|
122
|
+
// (task-cc-rankqueue-memoization); the server calls it from store.reload().
|
|
123
|
+
warmQueueIndex: require("./queue.js").warmQueueIndex,
|
|
111
124
|
EDGE_WEIGHTS,
|
|
112
125
|
KNOWN_TYPES,
|
|
113
126
|
KNOWN_EDGES,
|
package/lib/kernel/graph.js
CHANGED
|
@@ -152,10 +152,23 @@ function buildGraph(files, { nodesDir = "", seedSchemas = [] } = {}) {
|
|
|
152
152
|
|
|
153
153
|
const adj = {};
|
|
154
154
|
const supersededBy = {};
|
|
155
|
+
// danglingTo (task-cc-spor-tier-2-scale, §4.1 incremental cache): edges whose
|
|
156
|
+
// SOURCE is traversable but whose TARGET does not exist yet — the GRAPH.md
|
|
157
|
+
// "worth creating" markers. buildGraph skips them (no adjacency), but the
|
|
158
|
+
// incremental updater (applyNode) needs them: when the target is later
|
|
159
|
+
// created, those edges must light up, exactly as a from-scratch rebuild would
|
|
160
|
+
// make them. Keyed by the missing target id. A non-existent-but-non-
|
|
161
|
+
// traversable target is NOT dangling (it exists; the edge is permanently
|
|
162
|
+
// inactive), so only the `!nodes[e.to]` branch records here.
|
|
163
|
+
const danglingTo = {};
|
|
155
164
|
for (const n of Object.values(nodes)) {
|
|
156
165
|
if (!traversable(n)) continue;
|
|
157
166
|
for (const e of n.edges) {
|
|
158
|
-
if (!nodes[e.to]
|
|
167
|
+
if (!nodes[e.to]) {
|
|
168
|
+
(danglingTo[e.to] ??= []).push({ from: n.id, type: e.type, weight: reg.edgeWeight(e.type) });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (!traversable(nodes[e.to])) continue;
|
|
159
172
|
const w = reg.edgeWeight(e.type);
|
|
160
173
|
(adj[n.id] ??= []).push({ to: e.to, type: e.type, weight: w });
|
|
161
174
|
(adj[e.to] ??= []).push({ to: n.id, type: `${e.type} (inbound)`, weight: w });
|
|
@@ -187,8 +200,12 @@ function buildGraph(files, { nodesDir = "", seedSchemas = [] } = {}) {
|
|
|
187
200
|
postings[t] = { docs: new Int32Array(df[t]), w: new Float64Array(df[t]) };
|
|
188
201
|
cursor[t] = 0;
|
|
189
202
|
}
|
|
203
|
+
// docIndex (task-cc-spor-tier-2-scale, §4.1): id -> position in `docs`, so the
|
|
204
|
+
// incremental updater can find a node's doc slot in O(1) instead of scanning.
|
|
205
|
+
const docIndex = Object.create(null);
|
|
190
206
|
for (let i = 0; i < docs.length; i++) {
|
|
191
207
|
const d = docs[i];
|
|
208
|
+
docIndex[d.id] = i;
|
|
192
209
|
const dv = vec(d.tf); // transient; folded into the posting lists below
|
|
193
210
|
let nn = 0;
|
|
194
211
|
for (const t of Object.keys(dv)) {
|
|
@@ -202,11 +219,224 @@ function buildGraph(files, { nodesDir = "", seedSchemas = [] } = {}) {
|
|
|
202
219
|
delete d.tf; // dead after this; rankAgainst reads only postings + d.norm
|
|
203
220
|
}
|
|
204
221
|
|
|
205
|
-
return { nodes, adj, supersededBy, docs, df, N, postings, nodesDir, registry: reg,
|
|
222
|
+
return { nodes, adj, supersededBy, docs, df, N, postings, docIndex, danglingTo, nodesDir, registry: reg,
|
|
206
223
|
projectAliases: projectAliasMap(nodes), archivedProjects: archivedProjectSet(nodes),
|
|
207
224
|
groupingRepos: groupingRepoMap(nodes) };
|
|
208
225
|
}
|
|
209
226
|
|
|
227
|
+
// ---------- incremental cache update (SERVER.md §4.1, task-cc-spor-tier-2-scale) ----------
|
|
228
|
+
//
|
|
229
|
+
// applyNode(graph, text, file) mutates a resident graph IN PLACE to reflect one
|
|
230
|
+
// created or updated node, WITHOUT the O(corpus) rebuild loadGraph/buildGraph
|
|
231
|
+
// does on every write. At 50k nodes a full reload is ~3-5s, dominated entirely
|
|
232
|
+
// by re-tokenizing and re-vectorizing every node's tf-idf; this touches only the
|
|
233
|
+
// changed node's terms, so a write is O(node size + its terms' posting lengths),
|
|
234
|
+
// i.e. milliseconds. The structural maps (adj / supersededBy / danglingTo /
|
|
235
|
+
// docIndex) are patched with exactly the rules buildGraph applies, so the result
|
|
236
|
+
// is structurally identical to a rebuild.
|
|
237
|
+
//
|
|
238
|
+
// FIDELITY AND DRIFT. A from-scratch buildGraph scores every doc against the
|
|
239
|
+
// FINAL df/N. applyNode (re)scores ONLY the changed doc against the current
|
|
240
|
+
// df/N and leaves the already-resident docs' tf-idf weights at their last-build
|
|
241
|
+
// df/N. The per-write idf drift for an unchanged doc is log((N±1)/df) vs
|
|
242
|
+
// log(N/df) — vanishing against a large corpus — and is fully resynced by any
|
|
243
|
+
// full reload (boot, SIGHUP, the §4.1 HEAD poll, or the server's periodic
|
|
244
|
+
// resync after SPOR_RELOAD_EVERY writes). The changed doc itself is always
|
|
245
|
+
// scored exactly as a rebuild would score it. Posting-list array ORDER differs
|
|
246
|
+
// from a rebuild (append order vs directory order), but rankAgainst accumulates
|
|
247
|
+
// each doc's score across its query terms independent of intra-posting order, so
|
|
248
|
+
// only equal-sim tie ordering can differ — never a relevance result. The load-
|
|
249
|
+
// test harness (task-cc-spor-tier-2-scale) verifies top-K parity at scale.
|
|
250
|
+
//
|
|
251
|
+
// SCHEMA WRITES change the registry (edge weights, traversability) for the WHOLE
|
|
252
|
+
// graph and so cannot be patched locally — applyNode returns
|
|
253
|
+
// { reloadRequired: true } and the caller falls back to reload(). DELETIONS are
|
|
254
|
+
// likewise out of scope: the server write path never deletes a node, and an
|
|
255
|
+
// external delete arrives through the HEAD-poll full reload.
|
|
256
|
+
//
|
|
257
|
+
// Returns { id, mode: "create" | "update" } on success, or
|
|
258
|
+
// { id, reloadRequired: true } when a full reload is required instead.
|
|
259
|
+
function applyNode(graph, text, file) {
|
|
260
|
+
const node = parseFrontmatter(text, file || "incoming.md");
|
|
261
|
+
const id = node.id;
|
|
262
|
+
const reg = graph.registry;
|
|
263
|
+
// A schema node alters the registry globally; rebuild rather than patch.
|
|
264
|
+
if (node.type === "schema") return { id, reloadRequired: true };
|
|
265
|
+
|
|
266
|
+
// Invalidate the queue's memoized HEAD-pure indexes (task-cc-rankqueue-
|
|
267
|
+
// memoization): this write may change a blocks/resolves/answers/finding edge,
|
|
268
|
+
// supersession, or status that resolutionMap/openFindingsMap/blockersIndex/
|
|
269
|
+
// blockingCounts depend on, so the resident cache must not survive the
|
|
270
|
+
// mutation. The next rankQueue rebuilds it lazily; a full reload starts from a
|
|
271
|
+
// fresh graph object that never had one. Symbol.for keys the same slot
|
|
272
|
+
// queue.js sets, with no require cycle back into the queue kernel.
|
|
273
|
+
delete graph[Symbol.for("spor.queue.derived-index")];
|
|
274
|
+
|
|
275
|
+
const old = graph.nodes[id] || null;
|
|
276
|
+
const trav = (n) => !!n && reg.isTraversable(n.type);
|
|
277
|
+
|
|
278
|
+
// ---- structural maps: adjacency, supersededBy, danglingTo ----
|
|
279
|
+
// Remove the OLD node's own forward edges (and their inbound mirrors / dangling
|
|
280
|
+
// registrations) before installing the new node, then add the new node's.
|
|
281
|
+
if (trav(old)) removeNodeStructure(graph, old, reg);
|
|
282
|
+
graph.nodes[id] = node;
|
|
283
|
+
if (trav(node)) {
|
|
284
|
+
addNodeStructure(graph, node, reg);
|
|
285
|
+
activateDangling(graph, id, reg); // edges that were dangling onto this id light up
|
|
286
|
+
} else {
|
|
287
|
+
// A non-traversable node now exists: edges pointing at it stay permanently
|
|
288
|
+
// inactive (buildGraph skips a non-traversable target), so they are no
|
|
289
|
+
// longer "worth creating" markers — drop the dangling registrations.
|
|
290
|
+
delete graph.danglingTo[id];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---- tf-idf index: df / N / docs / postings (traversable nodes only) ----
|
|
294
|
+
// Type is fixed by the id prefix, so traversability is invariant across an
|
|
295
|
+
// update: a node that has a doc keeps one, and a non-traversable node never
|
|
296
|
+
// gets one.
|
|
297
|
+
if (trav(node)) {
|
|
298
|
+
const newTf = tf(tokens(`${node.title ?? ""} ${node.summary ?? ""} ${node.body}`));
|
|
299
|
+
const idx = old ? graph.docIndex[id] : null;
|
|
300
|
+
if (old && idx != null) {
|
|
301
|
+
// UPDATE: reuse the doc slot. Remove the old term contributions, then add
|
|
302
|
+
// the new — net df change is +1/-1 only for terms that entered or left.
|
|
303
|
+
const oldTf = tf(tokens(`${old.title ?? ""} ${old.summary ?? ""} ${old.body}`));
|
|
304
|
+
for (const t of Object.keys(oldTf)) {
|
|
305
|
+
if (graph.df[t] != null && (graph.df[t] -= 1) <= 0) delete graph.df[t];
|
|
306
|
+
removePosting(graph.postings, t, idx);
|
|
307
|
+
}
|
|
308
|
+
for (const t of Object.keys(newTf)) graph.df[t] = (graph.df[t] ?? 0) + 1;
|
|
309
|
+
// N is unchanged on an update. Score with the now-current df/N, exactly as
|
|
310
|
+
// a rebuild would score this doc in the post-update corpus.
|
|
311
|
+
const vec = makeVec(graph.df, graph.N);
|
|
312
|
+
const dv = vec(newTf);
|
|
313
|
+
let nn = 0;
|
|
314
|
+
for (const t of Object.keys(dv)) { const x = dv[t]; nn += x * x; pushPosting(graph.postings, t, idx, x); }
|
|
315
|
+
graph.docs[idx].norm = Math.sqrt(nn);
|
|
316
|
+
} else {
|
|
317
|
+
// CREATE: append a new doc slot.
|
|
318
|
+
for (const t of Object.keys(newTf)) graph.df[t] = (graph.df[t] ?? 0) + 1;
|
|
319
|
+
graph.N += 1;
|
|
320
|
+
const i = graph.docs.length;
|
|
321
|
+
const vec = makeVec(graph.df, graph.N);
|
|
322
|
+
const dv = vec(newTf);
|
|
323
|
+
let nn = 0;
|
|
324
|
+
for (const t of Object.keys(dv)) { const x = dv[t]; nn += x * x; pushPosting(graph.postings, t, i, x); }
|
|
325
|
+
graph.docs.push({ id, norm: Math.sqrt(nn) });
|
|
326
|
+
graph.docIndex[id] = i;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---- project / grouping / archived maps ----
|
|
331
|
+
// Only repo and project nodes feed these three maps; recompute them (each an
|
|
332
|
+
// O(N) scan with negligible per-node work) only when such a node is touched.
|
|
333
|
+
if (node.type === "repo" || node.type === "project" ||
|
|
334
|
+
(old && (old.type === "repo" || old.type === "project"))) {
|
|
335
|
+
graph.projectAliases = projectAliasMap(graph.nodes);
|
|
336
|
+
graph.archivedProjects = archivedProjectSet(graph.nodes);
|
|
337
|
+
graph.groupingRepos = groupingRepoMap(graph.nodes);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return { id, mode: old ? "update" : "create" };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Remove a (traversable) node's OWN forward edges from the adjacency, along with
|
|
344
|
+
// the inbound mirrors they placed on their targets, the supersession entries
|
|
345
|
+
// they set, and the dangling registrations they made onto not-yet-existent
|
|
346
|
+
// targets. Inbound entries OTHER nodes placed on this node are owned by those
|
|
347
|
+
// nodes and left untouched.
|
|
348
|
+
function removeNodeStructure(graph, n, reg) {
|
|
349
|
+
const id = n.id;
|
|
350
|
+
for (const e of n.edges) {
|
|
351
|
+
const t = graph.nodes[e.to];
|
|
352
|
+
if (!t) { dropDangling(graph, e.to, id, e.type); continue; }
|
|
353
|
+
if (!reg.isTraversable(t.type)) continue;
|
|
354
|
+
removeAdjEdge(graph.adj, id, e.to, e.type); // forward
|
|
355
|
+
removeAdjEdge(graph.adj, e.to, id, `${e.type} (inbound)`); // inbound mirror
|
|
356
|
+
if (e.type === "supersedes" && graph.supersededBy[e.to] === id) delete graph.supersededBy[e.to];
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Add a (traversable) node's forward edges to the adjacency (with inbound
|
|
361
|
+
// mirrors), set supersession, and register edges onto not-yet-existent targets
|
|
362
|
+
// as dangling — the exact rules buildGraph's adjacency loop applies.
|
|
363
|
+
function addNodeStructure(graph, n, reg) {
|
|
364
|
+
const id = n.id;
|
|
365
|
+
for (const e of n.edges) {
|
|
366
|
+
const t = graph.nodes[e.to];
|
|
367
|
+
const w = reg.edgeWeight(e.type);
|
|
368
|
+
if (!t) { (graph.danglingTo[e.to] ??= []).push({ from: id, type: e.type, weight: w }); continue; }
|
|
369
|
+
if (!reg.isTraversable(t.type)) continue;
|
|
370
|
+
(graph.adj[id] ??= []).push({ to: e.to, type: e.type, weight: w });
|
|
371
|
+
(graph.adj[e.to] ??= []).push({ to: id, type: `${e.type} (inbound)`, weight: w });
|
|
372
|
+
if (e.type === "supersedes") graph.supersededBy[e.to] = id;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// A node `id` was just created and is traversable: edges that pointed at it
|
|
377
|
+
// while it was missing now light up, both directions, exactly as a rebuild
|
|
378
|
+
// would build them once the target exists.
|
|
379
|
+
function activateDangling(graph, id, reg) {
|
|
380
|
+
const pend = graph.danglingTo[id];
|
|
381
|
+
if (!pend) return;
|
|
382
|
+
for (const d of pend) {
|
|
383
|
+
const from = graph.nodes[d.from];
|
|
384
|
+
if (!from || !reg.isTraversable(from.type)) continue; // source vanished/became inert
|
|
385
|
+
(graph.adj[d.from] ??= []).push({ to: id, type: d.type, weight: d.weight });
|
|
386
|
+
(graph.adj[id] ??= []).push({ to: d.from, type: `${d.type} (inbound)`, weight: d.weight });
|
|
387
|
+
if (d.type === "supersedes") graph.supersededBy[id] = d.from;
|
|
388
|
+
}
|
|
389
|
+
delete graph.danglingTo[id];
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function dropDangling(graph, to, from, type) {
|
|
393
|
+
const list = graph.danglingTo[to];
|
|
394
|
+
if (!list) return;
|
|
395
|
+
const i = list.findIndex((d) => d.from === from && d.type === type);
|
|
396
|
+
if (i >= 0) list.splice(i, 1);
|
|
397
|
+
if (list.length === 0) delete graph.danglingTo[to];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Remove ONE adjacency entry matching {to, type} from adj[from]. Weight is a
|
|
401
|
+
// pure function of type, so to+type identifies the entry; duplicate edges
|
|
402
|
+
// balance because the caller removes one per edge it adds.
|
|
403
|
+
function removeAdjEdge(adj, from, to, type) {
|
|
404
|
+
const list = adj[from];
|
|
405
|
+
if (!list) return;
|
|
406
|
+
const i = list.findIndex((x) => x.to === to && x.type === type);
|
|
407
|
+
if (i >= 0) list.splice(i, 1);
|
|
408
|
+
if (list.length === 0) delete adj[from];
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Append (docIndex, weight) to term t's posting list. The base lists are typed
|
|
412
|
+
// arrays sized exactly at build time (issue-cc-graph-vec-posting-lists); a list
|
|
413
|
+
// touched by an incremental write is promoted once to plain growable arrays
|
|
414
|
+
// (rankAgainst reads .length/index identically for both, and the Float64 values
|
|
415
|
+
// are unchanged, so scores stay bit-identical). The next full reload restores
|
|
416
|
+
// the compact typed-array form, bounding the memory give-back to terms touched
|
|
417
|
+
// since the last reload.
|
|
418
|
+
function pushPosting(postings, t, idx, w) {
|
|
419
|
+
let p = postings[t];
|
|
420
|
+
if (!p) { postings[t] = { docs: [idx], w: [w] }; return; }
|
|
421
|
+
if (ArrayBuffer.isView(p.docs)) { p.docs = Array.from(p.docs); p.w = Array.from(p.w); }
|
|
422
|
+
p.docs.push(idx); p.w.push(w);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Remove every entry for `idx` from term t's posting list (an update may have
|
|
426
|
+
// the same doc index appear once). Promotes a typed-array list to plain arrays.
|
|
427
|
+
function removePosting(postings, t, idx) {
|
|
428
|
+
const p = postings[t];
|
|
429
|
+
if (!p) return;
|
|
430
|
+
const docsArr = p.docs, wArr = p.w;
|
|
431
|
+
const nd = [], nw = [];
|
|
432
|
+
for (let k = 0; k < docsArr.length; k++) {
|
|
433
|
+
if (docsArr[k] === idx) continue;
|
|
434
|
+
nd.push(docsArr[k]); nw.push(wArr[k]);
|
|
435
|
+
}
|
|
436
|
+
if (nd.length === 0) delete postings[t];
|
|
437
|
+
else { p.docs = nd; p.w = nw; }
|
|
438
|
+
}
|
|
439
|
+
|
|
210
440
|
// ---------- project identity (task-cc-project-identity-nodes) ----------
|
|
211
441
|
|
|
212
442
|
// Slug -> project-node-id map from resident `type: project` nodes. Every
|
|
@@ -1065,6 +1295,7 @@ module.exports = {
|
|
|
1065
1295
|
buildRegistry,
|
|
1066
1296
|
schemaActive,
|
|
1067
1297
|
buildGraph,
|
|
1298
|
+
applyNode,
|
|
1068
1299
|
resolveProject,
|
|
1069
1300
|
groupingOf,
|
|
1070
1301
|
scopeFor,
|
package/lib/kernel/queue.js
CHANGED
|
@@ -107,8 +107,15 @@ function isLive(node, supersededBy) {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
// Distinct live nodes transitively reachable over OUTBOUND `blocks` edges
|
|
110
|
-
// (GRAPH.md: `n blocks t` == t cannot proceed until n does).
|
|
111
|
-
function
|
|
110
|
+
// (GRAPH.md: `n blocks t` == t cannot proceed until n does). The result is a
|
|
111
|
+
// pure function of the graph's HEAD (structure + status/supersession liveness),
|
|
112
|
+
// so an optional `memo` (Map id->count) caches it across rankQueue calls on a
|
|
113
|
+
// resident graph (task-cc-rankqueue-memoization) — this per-item traversal is
|
|
114
|
+
// the dominant queue-build cost at scale. The cache lives on the graph object
|
|
115
|
+
// (queueIndex below) and is invalidated by reload/applyNode, so a memoized hit
|
|
116
|
+
// is byte-identical to a fresh traversal (norm-cc-byte-identical-refactor).
|
|
117
|
+
function blockingCount(graph, id, memo) {
|
|
118
|
+
if (memo) { const c = memo.get(id); if (c !== undefined) return c; }
|
|
112
119
|
const seen = new Set();
|
|
113
120
|
const stack = [id];
|
|
114
121
|
while (stack.length) {
|
|
@@ -123,6 +130,7 @@ function blockingCount(graph, id) {
|
|
|
123
130
|
}
|
|
124
131
|
let live = 0;
|
|
125
132
|
for (const tid of seen) if (isLive(graph.nodes[tid], graph.supersededBy)) live++;
|
|
133
|
+
if (memo) memo.set(id, live);
|
|
126
134
|
return live;
|
|
127
135
|
}
|
|
128
136
|
|
|
@@ -324,21 +332,48 @@ function heatScore(graph, id, activity) {
|
|
|
324
332
|
return h;
|
|
325
333
|
}
|
|
326
334
|
|
|
327
|
-
// Fraction of this node's anchors that are superseded or missing.
|
|
328
|
-
function
|
|
335
|
+
// Fraction of this node's anchors that are superseded or missing. Like
|
|
336
|
+
// blockingCount this is a pure function of the graph's HEAD (edge structure +
|
|
337
|
+
// supersession), independent of every per-call input, so an optional `memo`
|
|
338
|
+
// (Map id->fraction) caches it across rankQueue calls on a resident graph
|
|
339
|
+
// (task-cc-rankqueue-staleness-date-memo). The cache lives on the graph object
|
|
340
|
+
// (queueIndex) and dies with reload/applyNode, so a hit returns the exact
|
|
341
|
+
// double a fresh scan produced (norm-cc-byte-identical-refactor).
|
|
342
|
+
function stalenessScore(graph, node, memo) {
|
|
343
|
+
if (memo) { const c = memo.get(node.id); if (c !== undefined) return c; }
|
|
329
344
|
const edges = node.edges ?? [];
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
345
|
+
let v;
|
|
346
|
+
if (!edges.length) v = 0;
|
|
347
|
+
else {
|
|
348
|
+
let stale = 0;
|
|
349
|
+
for (const e of edges) {
|
|
350
|
+
if (!graph.nodes[e.to] || graph.supersededBy[e.to]) stale++;
|
|
351
|
+
}
|
|
352
|
+
v = stale / edges.length;
|
|
334
353
|
}
|
|
335
|
-
|
|
354
|
+
if (memo) memo.set(node.id, v);
|
|
355
|
+
return v;
|
|
336
356
|
}
|
|
337
357
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
358
|
+
// The node's `date` parsed to epoch ms, or null when absent/malformed/
|
|
359
|
+
// unparseable. HEAD-pure — only the `(now - t)` in ageDays is per-call — so the
|
|
360
|
+
// parse is cacheable by id (task-cc-rankqueue-staleness-date-memo). A cached
|
|
361
|
+
// `null` (no usable date) is distinguished from "uncached" by the `undefined`
|
|
362
|
+
// check, so a dateless node is parsed at most once.
|
|
363
|
+
function nodeDateMs(node, memo) {
|
|
364
|
+
if (memo) { const c = memo.get(node.id); if (c !== undefined) return c; }
|
|
365
|
+
let t = null;
|
|
366
|
+
if (node.date && /^\d{4}-\d{2}-\d{2}/.test(node.date)) {
|
|
367
|
+
const p = Date.parse(node.date);
|
|
368
|
+
if (!Number.isNaN(p)) t = p;
|
|
369
|
+
}
|
|
370
|
+
if (memo) memo.set(node.id, t);
|
|
371
|
+
return t;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function ageDays(node, now, memo) {
|
|
375
|
+
const t = nodeDateMs(node, memo);
|
|
376
|
+
if (t == null) return null;
|
|
342
377
|
return Math.max(0, Math.floor((now - t) / 86_400_000));
|
|
343
378
|
}
|
|
344
379
|
|
|
@@ -421,6 +456,71 @@ function attachedSignals(reg, node, baseSignals, neighbors, sandboxFor) {
|
|
|
421
456
|
}
|
|
422
457
|
}
|
|
423
458
|
|
|
459
|
+
// HEAD-pure derived indexes the queue needs, memoized on the resident graph
|
|
460
|
+
// (task-cc-rankqueue-memoization). rankQueue rebuilt resolutionMap,
|
|
461
|
+
// openFindingsMap, blockersIndex, and a per-item transitive blockingCount on
|
|
462
|
+
// EVERY call — ~185ms at 50k nodes, dominated by the per-item work — even
|
|
463
|
+
// though every one of them is a pure function of the graph's HEAD, independent
|
|
464
|
+
// of the per-call inputs (now/leases/activity/viewer/project/filters). The
|
|
465
|
+
// server keeps the graph resident and only reloads when HEAD changes
|
|
466
|
+
// (dec-cc-defer-rust-rankqueue-kernel: the resident-graph principle, no wasm
|
|
467
|
+
// port), so these compute once and are reused across reads. Cache lifecycle is
|
|
468
|
+
// by graph identity, with no scheduler:
|
|
469
|
+
// - a fresh buildGraph()/loadGraph() result carries no cache, so a full
|
|
470
|
+
// reload self-invalidates (the new graph object simply has none);
|
|
471
|
+
// - applyNode() — the in-place incremental write path — deletes the cache
|
|
472
|
+
// (kernel/graph.js) since a mutation may change any blocks/resolves/
|
|
473
|
+
// answers/finding edge, supersession, or status the maps depend on; the
|
|
474
|
+
// next rankQueue rebuilds lazily.
|
|
475
|
+
// Keyed by a registry-global Symbol so it is invisible to Object.keys / JSON /
|
|
476
|
+
// spread (nothing enumerates the graph object) and graph.js can clear it
|
|
477
|
+
// without a require cycle back into queue.js. Outputs are byte-identical: the
|
|
478
|
+
// same pure functions over the same unchanged graph (norm-cc-byte-identical-
|
|
479
|
+
// refactor; the conformance queue goldens stand).
|
|
480
|
+
const QUEUE_INDEX = Symbol.for("spor.queue.derived-index");
|
|
481
|
+
|
|
482
|
+
function queueIndex(graph) {
|
|
483
|
+
let idx = graph[QUEUE_INDEX];
|
|
484
|
+
if (!idx) {
|
|
485
|
+
idx = graph[QUEUE_INDEX] = {
|
|
486
|
+
resolvedBy: resolution.resolutionMap(graph),
|
|
487
|
+
findingsFor: resolution.openFindingsMap(graph),
|
|
488
|
+
blockersOf: blockersIndex(graph),
|
|
489
|
+
blockingCounts: new Map(), // id -> transitive live-blocking count, lazily filled
|
|
490
|
+
stalenessScores: new Map(), // id -> edge-staleness fraction (HEAD-pure), lazily filled
|
|
491
|
+
dateMs: new Map(), // id -> parsed node.date ms or null (HEAD-pure), lazily filled
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
return idx;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Eagerly populate the memoized index for a RESIDENT graph so the FIRST queue
|
|
498
|
+
// read after a HEAD change pays no index-build cost on the request path — the
|
|
499
|
+
// "compute at load/build time" half of task-cc-rankqueue-memoization. The
|
|
500
|
+
// server calls this from store.reload() (already an O(corpus) rebuild, so the
|
|
501
|
+
// warm is negligible against it and happens only when HEAD changes); the
|
|
502
|
+
// incremental write path deliberately does NOT warm — it invalidates and lets
|
|
503
|
+
// the next read rebuild lazily, keeping put_node latency flat
|
|
504
|
+
// (task-cc-spor-tier-2-scale). Fills blockingCounts plus the per-node HEAD-pure
|
|
505
|
+
// scoring pieces — edge-staleness and the parsed node.date
|
|
506
|
+
// (task-cc-rankqueue-staleness-date-memo) — for every queueable-live, unresolved
|
|
507
|
+
// node, the superset any project/assignee/type-filtered rankQueue draws from, so
|
|
508
|
+
// every later read is a pure cache hit. Returns the index.
|
|
509
|
+
function warmQueueIndex(graph) {
|
|
510
|
+
const idx = queueIndex(graph);
|
|
511
|
+
const reg = graph.registry;
|
|
512
|
+
if (!reg || typeof reg.isQueueable !== "function") return idx;
|
|
513
|
+
for (const node of Object.values(graph.nodes)) {
|
|
514
|
+
if (!reg.isQueueable(node.type)) continue;
|
|
515
|
+
if (!isLive(node, graph.supersededBy)) continue;
|
|
516
|
+
if (idx.resolvedBy[node.id]) continue; // retired by a live resolves/answers edge
|
|
517
|
+
blockingCount(graph, node.id, idx.blockingCounts);
|
|
518
|
+
stalenessScore(graph, node, idx.stalenessScores);
|
|
519
|
+
nodeDateMs(node, idx.dateMs);
|
|
520
|
+
}
|
|
521
|
+
return idx;
|
|
522
|
+
}
|
|
523
|
+
|
|
424
524
|
// rankQueue(graph, { project?, assignee?, includeTypes?, excludeTypes?, activity?, front?, leases?, now?, limit?, viewer?, sandboxFor? })
|
|
425
525
|
// -> { items, count, muted?, dormant?, archived?, leased?, reserved? }
|
|
426
526
|
// `includeTypes`/`excludeTypes` (task-cc-queue-filtering-enhancements) are
|
|
@@ -548,11 +648,23 @@ function rankQueue(graph, opts = {}) {
|
|
|
548
648
|
// questions already do — zero mutation, the status flip stays a human act
|
|
549
649
|
// (the gardener's resolved-open finding nags for it). Open findings about
|
|
550
650
|
// an item ride along so the consumer sees what the gardener already knows.
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
651
|
+
// HEAD-pure derived indexes, memoized on the resident graph and reused across
|
|
652
|
+
// reads (task-cc-rankqueue-memoization); recomputed lazily on a fresh/mutated
|
|
653
|
+
// graph. Independent of the per-call inputs above, so caching is sound.
|
|
654
|
+
const idx = queueIndex(graph);
|
|
655
|
+
const { resolvedBy, findingsFor, blockersOf } = idx;
|
|
554
656
|
|
|
555
657
|
const items = [];
|
|
658
|
+
// Deferred per-item RENDER (issue-cc-queue-percall-blend-latency-floor,
|
|
659
|
+
// lever 1): the presentation labels (suggest, the why-line and its blocker/
|
|
660
|
+
// cross-project descriptors, the wake/lease/findings annotations) are built
|
|
661
|
+
// AFTER sort+slice for just the `limit` items actually returned, not for every
|
|
662
|
+
// live node. The only render input not cheap to recompute for the survivors is
|
|
663
|
+
// each node's live-blocker set (already allocated for `blocked_by` and needed
|
|
664
|
+
// for the cap pass over ALL items), so it rides in this id->blockers map; the
|
|
665
|
+
// render pass recomputes node/lease/wake from the id. An entry's presence also
|
|
666
|
+
// marks a normal (vs schema-approval) item for the render pass.
|
|
667
|
+
const blockersById = new Map();
|
|
556
668
|
for (const node of Object.values(graph.nodes)) {
|
|
557
669
|
// Schema-change proposals are queue items (§2.4/§5): a proposed schema
|
|
558
670
|
// waits for a human to review the payload + attached code and flip its
|
|
@@ -563,7 +675,7 @@ function rankQueue(graph, opts = {}) {
|
|
|
563
675
|
if (!inTypes("schema")) continue; // hard type scope, uncounted (like project)
|
|
564
676
|
if (isArchived(node)) { archivedCount++; continue; }
|
|
565
677
|
if (isMuted(node)) { muted++; continue; }
|
|
566
|
-
const age = ageDays(node, now);
|
|
678
|
+
const age = ageDays(node, now, idx.dateMs);
|
|
567
679
|
items.push({
|
|
568
680
|
id: node.id,
|
|
569
681
|
title: node.title ?? null,
|
|
@@ -605,28 +717,17 @@ function rankQueue(graph, opts = {}) {
|
|
|
605
717
|
|
|
606
718
|
const blockers = liveBlockers(graph, node.id, blockersOf, resolvedBy);
|
|
607
719
|
const signals = {
|
|
608
|
-
blocking: blockingCount(graph, node.id),
|
|
720
|
+
blocking: blockingCount(graph, node.id, idx.blockingCounts),
|
|
609
721
|
blocked_by: blockers.length,
|
|
610
722
|
front: front?.[node.id] ?? 0, // this node only — no neighborhood spread
|
|
611
723
|
heat: heatScore(graph, node.id, activity),
|
|
612
|
-
staleness: Number(stalenessScore(graph, node).toFixed(2)),
|
|
613
|
-
age_days: ageDays(node, now),
|
|
724
|
+
staleness: Number(stalenessScore(graph, node, idx.stalenessScores).toFixed(2)),
|
|
725
|
+
age_days: ageDays(node, now, idx.dateMs),
|
|
614
726
|
};
|
|
615
727
|
// needed_by_days rides along ONLY when the node carries a deadline, so the
|
|
616
728
|
// signals shape (and every existing why-line/golden) is unchanged without it.
|
|
617
729
|
const nbDays = neededByDays(neededByTime(node), now);
|
|
618
730
|
if (nbDays != null) signals.needed_by_days = nbDays;
|
|
619
|
-
// Cross-project provenance descriptors for the why-line: blockers carry
|
|
620
|
-
// their project (annotated only when it differs), and the direct blocks
|
|
621
|
-
// targets in another project name who this dependency serves.
|
|
622
|
-
const ownProject = node.project ?? null;
|
|
623
|
-
const blockerDescs = blockers.map((id) => {
|
|
624
|
-
const p = graph.nodes[id]?.project ?? null;
|
|
625
|
-
return { id, project: p, cross: p !== ownProject };
|
|
626
|
-
});
|
|
627
|
-
const crossBlocking = directLiveBlocks(graph, node, resolvedBy)
|
|
628
|
-
.filter((t) => (t.project ?? null) !== ownProject)
|
|
629
|
-
.map((t) => ({ id: t.id, project: t.project ?? null }));
|
|
630
731
|
const bump = PRIORITY_BUMP[node.priority] ?? 0;
|
|
631
732
|
let score = bump + 3 * signals.blocking - 3 * signals.blocked_by +
|
|
632
733
|
Math.min(Math.log2(1 + signals.front), 5) +
|
|
@@ -642,6 +743,10 @@ function rankQueue(graph, opts = {}) {
|
|
|
642
743
|
}
|
|
643
744
|
if (attached?.error) signals.queue_signals_error = attached.error;
|
|
644
745
|
|
|
746
|
+
// Ranking fields only; the presentation labels (suggest/why/blocked_by/
|
|
747
|
+
// lease/findings) are deferred to the post-slice render pass below
|
|
748
|
+
// (issue-cc-queue-percall-blend-latency-floor). Keys are inserted in the
|
|
749
|
+
// same order render later appends to, so the serialized item is unchanged.
|
|
645
750
|
const item = {
|
|
646
751
|
id: node.id,
|
|
647
752
|
title: node.title ?? null,
|
|
@@ -651,33 +756,8 @@ function rankQueue(graph, opts = {}) {
|
|
|
651
756
|
priority: node.priority ?? null,
|
|
652
757
|
score: Number(score.toFixed(2)),
|
|
653
758
|
signals,
|
|
654
|
-
// staleness wins (closing retires the item, blocked or not), then
|
|
655
|
-
// blocked — an item gated by live work isn't actionable either way.
|
|
656
|
-
suggest: signals.staleness >= STALE_SUGGEST_THRESHOLD ? "close"
|
|
657
|
-
: blockers.length ? "blocked" : "do",
|
|
658
|
-
why: whyLine(signals, node, blockerDescs, crossBlocking, frontDays),
|
|
659
759
|
};
|
|
660
|
-
|
|
661
|
-
if (blockers.length) item.blocked_by = blockers;
|
|
662
|
-
// A surviving lease rides along as `lease_state` so the consumer can render
|
|
663
|
-
// it (dec-cc-task-claim-lease's "demoted-not-erased" / the steward view's
|
|
664
|
-
// "in progress by X"). Only two ways an item gets here with a lease: the
|
|
665
|
-
// viewer holds it (their own queue), or this is the lease-exempt steward
|
|
666
|
-
// read. Tier-1 live -> "in progress", Tier-2 reservation -> "reserved"; the
|
|
667
|
-
// owner sees "yours", a steward sees "by <holder>". Counted demotions
|
|
668
|
-
// (held by another, hidden) never reach here — they `continue`d above.
|
|
669
|
-
if (lease) {
|
|
670
|
-
const mine = lease.by === viewerHolder;
|
|
671
|
-
item.lease_state = lease.reserved ? "reserved" : "in_progress";
|
|
672
|
-
item.lease_by = lease.by;
|
|
673
|
-
const label = lease.reserved ? "reserved" : "in progress";
|
|
674
|
-
item.why += mine ? `; ${label} (your claim)` : `; ${label} by ${lease.by}`;
|
|
675
|
-
}
|
|
676
|
-
const flagged = findingsFor[node.id];
|
|
677
|
-
if (flagged?.length) {
|
|
678
|
-
item.findings = flagged.map((f) => f.id);
|
|
679
|
-
item.why += `; ${flagged.length} open gardener finding${flagged.length === 1 ? "" : "s"}: ${item.findings.join(", ")}`;
|
|
680
|
-
}
|
|
760
|
+
blockersById.set(node.id, blockers);
|
|
681
761
|
items.push(item);
|
|
682
762
|
}
|
|
683
763
|
|
|
@@ -690,7 +770,9 @@ function rankQueue(graph, opts = {}) {
|
|
|
690
770
|
for (let pass = 0; pass < 10; pass++) {
|
|
691
771
|
let changed = false;
|
|
692
772
|
for (const it of items) {
|
|
693
|
-
|
|
773
|
+
// blockers ride in blockersById until render re-adds `blocked_by` post-slice
|
|
774
|
+
// (deferred render); schema-approval items aren't in the map -> no blockers.
|
|
775
|
+
for (const bid of blockersById.get(it.id) ?? []) {
|
|
694
776
|
const blocker = byId.get(bid);
|
|
695
777
|
if (blocker && it.score >= blocker.score) {
|
|
696
778
|
it.score = Number((blocker.score - 0.01).toFixed(2));
|
|
@@ -734,7 +816,65 @@ function rankQueue(graph, opts = {}) {
|
|
|
734
816
|
(b.signals.age_days ?? 0) - (a.signals.age_days ?? 0) ||
|
|
735
817
|
(a.id < b.id ? -1 : 1));
|
|
736
818
|
|
|
737
|
-
|
|
819
|
+
// Render pass (issue-cc-queue-percall-blend-latency-floor, lever 1): build the
|
|
820
|
+
// presentation labels for ONLY the items the slice returns. The same labels the
|
|
821
|
+
// per-node loop used to build for every live node — suggest, the why-line with
|
|
822
|
+
// its blocker/cross-project descriptors, the wake/lease/findings annotations,
|
|
823
|
+
// and the re-added `blocked_by` — now cost O(limit), not O(live queue), cutting
|
|
824
|
+
// the per-call string/array allocations that drove the p95/p99 GC tail. Each
|
|
825
|
+
// appended key lands in its original position, so a returned item is
|
|
826
|
+
// byte-identical (norm-cc-byte-identical-refactor). Schema-approval items carry
|
|
827
|
+
// a constant why and aren't in blockersById, so they're left as rendered inline.
|
|
828
|
+
const top = items.slice(0, Math.max(0, limit));
|
|
829
|
+
for (const item of top) {
|
|
830
|
+
const blockers = blockersById.get(item.id);
|
|
831
|
+
if (blockers === undefined) continue; // schema-approval item: rendered inline
|
|
832
|
+
const node = graph.nodes[item.id];
|
|
833
|
+
// lease/wake are cheap to recompute for the survivors from the same `now`,
|
|
834
|
+
// so they aren't retained per node; identical to the loop's computation.
|
|
835
|
+
const lease = leaseState(leases, node.id, now);
|
|
836
|
+
const wake = wakeTime(node);
|
|
837
|
+
const signals = item.signals;
|
|
838
|
+
// staleness wins (closing retires the item, blocked or not), then blocked —
|
|
839
|
+
// an item gated by live work isn't actionable either way.
|
|
840
|
+
item.suggest = signals.staleness >= STALE_SUGGEST_THRESHOLD ? "close"
|
|
841
|
+
: blockers.length ? "blocked" : "do";
|
|
842
|
+
// Cross-project provenance descriptors for the why-line: blockers carry their
|
|
843
|
+
// project (annotated only when it differs), and the direct blocks targets in
|
|
844
|
+
// another project name who this dependency serves.
|
|
845
|
+
const ownProject = node.project ?? null;
|
|
846
|
+
const blockerDescs = blockers.map((id) => {
|
|
847
|
+
const p = graph.nodes[id]?.project ?? null;
|
|
848
|
+
return { id, project: p, cross: p !== ownProject };
|
|
849
|
+
});
|
|
850
|
+
const crossBlocking = directLiveBlocks(graph, node, resolvedBy)
|
|
851
|
+
.filter((t) => (t.project ?? null) !== ownProject)
|
|
852
|
+
.map((t) => ({ id: t.id, project: t.project ?? null }));
|
|
853
|
+
item.why = whyLine(signals, node, blockerDescs, crossBlocking, frontDays);
|
|
854
|
+
if (wake != null) item.why += `; woke ${node.wake} (was dormant)`;
|
|
855
|
+
if (blockers.length) item.blocked_by = blockers;
|
|
856
|
+
// A surviving lease rides along as `lease_state` so the consumer can render
|
|
857
|
+
// it (dec-cc-task-claim-lease's "demoted-not-erased" / the steward view's
|
|
858
|
+
// "in progress by X"). Only two ways an item gets here with a lease: the
|
|
859
|
+
// viewer holds it (their own queue), or this is the lease-exempt steward
|
|
860
|
+
// read. Tier-1 live -> "in progress", Tier-2 reservation -> "reserved"; the
|
|
861
|
+
// owner sees "yours", a steward sees "by <holder>". Counted demotions
|
|
862
|
+
// (held by another, hidden) never reach here — they `continue`d above.
|
|
863
|
+
if (lease) {
|
|
864
|
+
const mine = lease.by === viewerHolder;
|
|
865
|
+
item.lease_state = lease.reserved ? "reserved" : "in_progress";
|
|
866
|
+
item.lease_by = lease.by;
|
|
867
|
+
const label = lease.reserved ? "reserved" : "in progress";
|
|
868
|
+
item.why += mine ? `; ${label} (your claim)` : `; ${label} by ${lease.by}`;
|
|
869
|
+
}
|
|
870
|
+
const flagged = findingsFor[node.id];
|
|
871
|
+
if (flagged?.length) {
|
|
872
|
+
item.findings = flagged.map((f) => f.id);
|
|
873
|
+
item.why += `; ${flagged.length} open gardener finding${flagged.length === 1 ? "" : "s"}: ${item.findings.join(", ")}`;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const r = { items: top, count: items.length };
|
|
738
878
|
if (muted > 0) r.muted = muted;
|
|
739
879
|
if (dormant > 0) r.dormant = dormant;
|
|
740
880
|
if (archivedCount > 0) r.archived = archivedCount;
|
|
@@ -746,4 +886,4 @@ function rankQueue(graph, opts = {}) {
|
|
|
746
886
|
return r;
|
|
747
887
|
}
|
|
748
888
|
|
|
749
|
-
module.exports = { rankQueue, PRIORITY_BUMP, DEFAULT_LIMIT };
|
|
889
|
+
module.exports = { rankQueue, warmQueueIndex, PRIORITY_BUMP, DEFAULT_LIMIT };
|
package/lib/queue.js
CHANGED
|
@@ -100,7 +100,7 @@ function gitFront(repoDir, nodesName, days) {
|
|
|
100
100
|
return out;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
module.exports = { rankQueue, gitFront, gitIdentityEmail, viewerFor, PRIORITY_BUMP: kernel.PRIORITY_BUMP, DEFAULT_LIMIT: kernel.DEFAULT_LIMIT };
|
|
103
|
+
module.exports = { rankQueue, warmQueueIndex: kernel.warmQueueIndex, gitFront, gitIdentityEmail, viewerFor, PRIORITY_BUMP: kernel.PRIORITY_BUMP, DEFAULT_LIMIT: kernel.DEFAULT_LIMIT };
|
|
104
104
|
|
|
105
105
|
// ---------- CLI (local mode / debugging; new entry point, existing CLIs untouched) ----------
|
|
106
106
|
|
package/lib/remote.js
CHANGED
|
@@ -27,8 +27,11 @@ function isRemote(cfg) {
|
|
|
27
27
|
|
|
28
28
|
// One request. Returns { ok, status, json, text } on an HTTP response (any
|
|
29
29
|
// status), or { ok:false, transport:true, error } when the request never
|
|
30
|
-
// completed. Never throws.
|
|
31
|
-
|
|
30
|
+
// completed. Never throws. `opts.token` overrides the cfg-resolved bearer for
|
|
31
|
+
// this one call — `spor dispatch` uses it to authenticate as the freshly-minted
|
|
32
|
+
// agent token (not the person token) when late-binding the run session
|
|
33
|
+
// (issue-spor-dispatch-bg-session-late-bind).
|
|
34
|
+
async function request(cfg, method, apiPath, { body, timeoutMs = 6000, token: tokenOverride } = {}) {
|
|
32
35
|
const url = base(cfg) + apiPath;
|
|
33
36
|
const ctrl = new AbortController();
|
|
34
37
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
@@ -36,7 +39,7 @@ async function request(cfg, method, apiPath, { body, timeoutMs = 6000 } = {}) {
|
|
|
36
39
|
const res = await fetch(url, {
|
|
37
40
|
method,
|
|
38
41
|
headers: {
|
|
39
|
-
Authorization: `Bearer ${token(cfg)}`,
|
|
42
|
+
Authorization: `Bearer ${tokenOverride || token(cfg)}`,
|
|
40
43
|
...(body != null ? { "Content-Type": "application/json" } : {}),
|
|
41
44
|
},
|
|
42
45
|
body: body != null ? JSON.stringify(body) : undefined,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sporhq/spor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Spor — a shared memory substrate for teams and agents. Decisions, their reasons, and the traces they leave. Knowledge-graph context compiler: session-start briefings, per-prompt digests, capture at discovery, end-of-session distillation, decision queue.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Anthony Allen",
|