agents-can-communicate 0.1.13 → 0.1.14
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/docs/CAPABILITIES.md +32 -2
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/claims.mjs +2 -2
- package/node_modules/@agents-can-communicate/core/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/core/src/pid.mjs +33 -0
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +9 -0
- package/node_modules/@agents-can-communicate/core/src/service.mjs +12 -3
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +47 -7
- package/node_modules/@agents-can-communicate/core/src/status.mjs +17 -10
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +23 -8
- package/node_modules/@agents-can-communicate/core/src/tasks.mjs +11 -5
- package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +2 -2
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/client-pid.mjs +32 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +34 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +15 -3
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +5 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
- package/package.json +1 -1
package/docs/CAPABILITIES.md
CHANGED
|
@@ -44,6 +44,34 @@ should be re-run before claiming another platform.
|
|
|
44
44
|
| `execution.resume` | no | no | no | no |
|
|
45
45
|
| `execution.terminate` | no | no | no | no |
|
|
46
46
|
|
|
47
|
+
## Resolving a client's pid is not universal either
|
|
48
|
+
|
|
49
|
+
Not a capability above - no adapter method backs it, so it has no row in the matrix - but
|
|
50
|
+
it is presence's other signal for telling a dead process from an idle one, and it does not
|
|
51
|
+
reach every client.
|
|
52
|
+
|
|
53
|
+
A session's recorded pid comes from walking its process ancestry until the adapter's own
|
|
54
|
+
declared binary (`client.command`) turns up in `ps -o comm=`. That only works when the
|
|
55
|
+
operating system's own name for the process actually is that binary: true for a native
|
|
56
|
+
executable, false for a script run through an interpreter, where `comm` reports the
|
|
57
|
+
interpreter's name rather than the script's.
|
|
58
|
+
|
|
59
|
+
| Client | `client.command` | `ps -o comm=` reports | Pid resolves |
|
|
60
|
+
|---|---|---|---|
|
|
61
|
+
| `codex` | `codex` | `codex`, a native binary | yes |
|
|
62
|
+
| `claude_code` | `claude` | `claude`, a native binary | yes |
|
|
63
|
+
| `gemini_cli` | `gemini` | `node` - `gemini.js` starts `#!/usr/bin/env node` | **no** |
|
|
64
|
+
| `kimi` | `kimi` | not installed on the machine this table was measured on; Kimi Code ships via npm as a Node.js CLI (`@moonshot-ai/kimi-code`), the same shape as Gemini CLI | **almost certainly no - not measured** |
|
|
65
|
+
|
|
66
|
+
A Gemini session records `pid: null` for its whole life, and Kimi's is very likely the
|
|
67
|
+
same, unconfirmed. `null` is the correct "nobody knows" answer and is handled identically
|
|
68
|
+
wherever it is read - not a correctness bug. It does change what presence delivers, though:
|
|
69
|
+
a confirmed-dead pid retires a session immediately and exactly, and today that is `codex`
|
|
70
|
+
and `claude_code` only. `gemini_cli` and `kimi` fall back to the same age-based floor every
|
|
71
|
+
session has for whenever a pid is unavailable - thirty minutes of silence - so their
|
|
72
|
+
sessions still leave, just later and on a timer instead of on the fact. See
|
|
73
|
+
[ARCHITECTURE.md](ARCHITECTURE.md#presence) for the full floor.
|
|
74
|
+
|
|
47
75
|
## What the yes values do not promise
|
|
48
76
|
|
|
49
77
|
A capability says the client can do the thing. Several of them are conditional on how the
|
|
@@ -85,8 +113,10 @@ what the claim's owner asked for, and whether ACC can stop this session at all:
|
|
|
85
113
|
Unenforceable is not the same as unknown - and neither is it the same as unclaimed.
|
|
86
114
|
|
|
87
115
|
**`lifecycle.sessionEnd` on `kimi` is false and it matters.** Each `kimi -p` run leaves an
|
|
88
|
-
attached session that
|
|
89
|
-
|
|
116
|
+
attached session that never closes itself - it just stops taking turns. Presence retires it
|
|
117
|
+
instead, most likely without ever resolving a pid (see above), which means the session
|
|
118
|
+
reads `offline` - and disappears from the default `acc status` view - only after thirty
|
|
119
|
+
minutes of silence, not on its declared 60s heartbeat cadence. Interactive sessions
|
|
90
120
|
heartbeat and do not have this problem.
|
|
91
121
|
|
|
92
122
|
**`lifecycle.heartbeat` is Kimi's alone.** It fires on a timer - observed at 60002, 120004
|
|
@@ -31,7 +31,7 @@ export function overlaps(left, right) {
|
|
|
31
31
|
const isLive = (claim, now) => Date.parse(claim.expiresAt) > Date.parse(now);
|
|
32
32
|
|
|
33
33
|
export function createClaimService(ports, sessions) {
|
|
34
|
-
const { store, clock, ids } = ports;
|
|
34
|
+
const { store, clock, ids, pidIsAlive } = ports;
|
|
35
35
|
|
|
36
36
|
async function requireOwner(input, action) {
|
|
37
37
|
const existing = await sessions.locateSession(input.sessionId, input.workspaceId);
|
|
@@ -52,7 +52,7 @@ export function createClaimService(ports, sessions) {
|
|
|
52
52
|
// releases the claim on its own.
|
|
53
53
|
const ownerPresence = ownerSession === undefined
|
|
54
54
|
? "offline"
|
|
55
|
-
: classifySessionPresence(ownerSession, now);
|
|
55
|
+
: classifySessionPresence(ownerSession, now, pidIsAlive);
|
|
56
56
|
return new AccError(EXIT.CONFLICT, "the resource is already claimed", {
|
|
57
57
|
claimId: existing.claimId,
|
|
58
58
|
resource: existing.resource,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export { createCoordinationService } from "./service.mjs";
|
|
3
3
|
export { assertPorts } from "./ports.mjs";
|
|
4
4
|
export { classifySessionPresence } from "./sessions.mjs";
|
|
5
|
+
export { defaultPidIsAlive } from "./pid.mjs";
|
|
5
6
|
export { ATTENTION_PRIORITY, computeAttention } from "./sync.mjs";
|
|
6
7
|
export { overlaps } from "./claims.mjs";
|
|
7
8
|
export { wouldCycle } from "./tasks.mjs";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a process with this id exists.
|
|
3
|
+
*
|
|
4
|
+
* Signal 0 runs the existence and permission checks without delivering
|
|
5
|
+
* anything. `ESRCH` is the only answer that means gone: `EPERM` says the
|
|
6
|
+
* process is there and owned by somebody else, which is still there.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately not shared with the writer lock's copy in the storage package.
|
|
9
|
+
* `core` may not import storage (tests/package-boundaries.test.mjs), and the two
|
|
10
|
+
* ask the question about different subjects - a lock owner mid-write, and a
|
|
11
|
+
* session that may have ended hours ago.
|
|
12
|
+
*
|
|
13
|
+
* `pid` must be a real, positive process id - the argument this function
|
|
14
|
+
* answers a question about. It is not where "nobody knows" is answered:
|
|
15
|
+
* `defaultPidIsAlive(null)` returns `false`, the same as a confirmed-dead pid,
|
|
16
|
+
* because a non-positive integer fails the `Number.isInteger` guard below the
|
|
17
|
+
* same way a made-up one would. The design this function serves requires the
|
|
18
|
+
* opposite reading for a session with no recorded pid - "cannot tell", never
|
|
19
|
+
* "dead" - so every call site in this repository guards on `pid !== null`
|
|
20
|
+
* before calling it. This is exported as the canonical liveness probe, so a
|
|
21
|
+
* caller reached from outside that guard must add its own, or a pid-less
|
|
22
|
+
* session will read as dead here regardless of how the rest of the system
|
|
23
|
+
* treats it.
|
|
24
|
+
*/
|
|
25
|
+
export function defaultPidIsAlive(pid) {
|
|
26
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
return true;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return error.code !== "ESRCH";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -52,5 +52,14 @@ export function assertPorts(ports) {
|
|
|
52
52
|
{ port: "store.ephemeral", method });
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
// A bare function, not an object with methods, so it does not fit REQUIRED
|
|
56
|
+
// above. Checked here for the same reason as everything else in this file: a
|
|
57
|
+
// core that constructed cleanly and only discovered the gap at the first
|
|
58
|
+
// presence check would raise a bare TypeError from deep inside sessions.mjs,
|
|
59
|
+
// not an AccError - invisible to the CLI's exit-code mapping.
|
|
60
|
+
if (typeof ports.pidIsAlive !== "function") {
|
|
61
|
+
throw new AccError(EXIT.USAGE, "the pidIsAlive port must be a function",
|
|
62
|
+
{ port: "pidIsAlive" });
|
|
63
|
+
}
|
|
55
64
|
return ports;
|
|
56
65
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createClaimService } from "./claims.mjs";
|
|
2
2
|
import { createCommunicationService } from "./communication.mjs";
|
|
3
3
|
import { createIntentService } from "./intents.mjs";
|
|
4
|
+
import { defaultPidIsAlive } from "./pid.mjs";
|
|
4
5
|
import { assertPorts } from "./ports.mjs";
|
|
5
6
|
import { createSessionService } from "./sessions.mjs";
|
|
6
7
|
import { createGuardStateService, createStatusService } from "./status.mjs";
|
|
@@ -13,10 +14,18 @@ import { createWorkstreamService } from "./workstreams.mjs";
|
|
|
13
14
|
* randomness-dependent arrives through a port, so behaviour is reproducible and
|
|
14
15
|
* no module reaches for a global.
|
|
15
16
|
*
|
|
16
|
-
* @param {{ store: object, clock: object, ids: object,
|
|
17
|
+
* @param {{ store: object, clock: object, ids: object, pidIsAlive?: function,
|
|
18
|
+
* policies?: object }} ports
|
|
17
19
|
*/
|
|
18
|
-
export function createCoordinationService({ store, clock, ids,
|
|
19
|
-
|
|
20
|
+
export function createCoordinationService({ store, clock, ids,
|
|
21
|
+
pidIsAlive = defaultPidIsAlive, policies = {} }) {
|
|
22
|
+
// Defaulted here, where the default is a real implementation, and required in
|
|
23
|
+
// the classifier, where a default could only be a lie. Passed into
|
|
24
|
+
// assertPorts rather than spread on afterward, so an explicit non-function -
|
|
25
|
+
// `null` included, since the default above only applies to `undefined` - is
|
|
26
|
+
// shape-checked at construction like every other port instead of surfacing
|
|
27
|
+
// as a raw TypeError the first time presence is classified.
|
|
28
|
+
const ports = assertPorts({ store, clock, ids, pidIsAlive });
|
|
20
29
|
const sessions = createSessionService(ports);
|
|
21
30
|
const intents = createIntentService(ports, sessions);
|
|
22
31
|
const workstreams = createWorkstreamService(ports, sessions);
|
|
@@ -9,14 +9,46 @@ import { writeWorkResponse } from "./notify.mjs";
|
|
|
9
9
|
// rather than one global constant (docs/ARCHITECTURE.md, presence freshness).
|
|
10
10
|
const STALE_CADENCE_MULTIPLE = 3;
|
|
11
11
|
|
|
12
|
+
// Two floors, because they answer different questions and neither subsumes the
|
|
13
|
+
// other. UNKNOWN_EXPIRY_MS is the "cannot tell" branch: records written before
|
|
14
|
+
// pids were recorded, platforms with no process table, an ancestry that did not
|
|
15
|
+
// resolve. HARD_EXPIRY_MS exists because pids are recycled - the hazard
|
|
16
|
+
// writer-mutex.mjs:72 documents - so a session whose number was reissued to
|
|
17
|
+
// something unrelated would otherwise read as alive forever.
|
|
18
|
+
const UNKNOWN_EXPIRY_MS = 30 * 60_000;
|
|
19
|
+
const HARD_EXPIRY_MS = 24 * 60 * 60_000;
|
|
20
|
+
|
|
21
|
+
const ageBand = (session, age) =>
|
|
22
|
+
age <= session.heartbeatCadenceMs * STALE_CADENCE_MULTIPLE ? "online" : "stale";
|
|
23
|
+
|
|
12
24
|
/**
|
|
25
|
+
* @param {{ state: string, heartbeatAt: string, heartbeatCadenceMs: number,
|
|
26
|
+
* pid?: number | null }} session The record to classify. `pid` absent or
|
|
27
|
+
* `null` means nobody knows whether the process is alive - never that it is
|
|
28
|
+
* dead - so age alone judges it.
|
|
29
|
+
* @param {string} now An ISO timestamp, compared against `session.heartbeatAt`.
|
|
30
|
+
* @param {(pid: number) => boolean} pidIsAlive Required, not defaulted: the one
|
|
31
|
+
* thing that lets `offline` be reached before the age floors do. Called only
|
|
32
|
+
* when `session.pid` is a real pid, never with `null`.
|
|
13
33
|
* @returns {"online" | "stale" | "offline"}
|
|
34
|
+
* @throws {AccError} EXIT.USAGE when pidIsAlive is not a function.
|
|
14
35
|
*/
|
|
15
|
-
export function classifySessionPresence(session, now,
|
|
36
|
+
export function classifySessionPresence(session, now, pidIsAlive) {
|
|
37
|
+
// Required rather than defaulted. A probe that defaults to "everyone is
|
|
38
|
+
// alive" turns a forgotten call site into a check that silently passes, which
|
|
39
|
+
// is the failure this repository has already shipped twice.
|
|
40
|
+
if (typeof pidIsAlive !== "function") {
|
|
41
|
+
throw new AccError(EXIT.USAGE, "classifySessionPresence requires a pidIsAlive probe",
|
|
42
|
+
{ sessionId: session?.sessionId ?? null });
|
|
43
|
+
}
|
|
16
44
|
if (session.state === "closed") return "offline";
|
|
17
|
-
if (!probe(session)) return "offline";
|
|
18
45
|
const age = Date.parse(now) - Date.parse(session.heartbeatAt);
|
|
19
|
-
|
|
46
|
+
if (age > HARD_EXPIRY_MS) return "offline";
|
|
47
|
+
const pid = session.pid ?? null;
|
|
48
|
+
// A pid that answers outranks the unknown floor: a live but idle session is
|
|
49
|
+
// exactly what kimi looks like between turns.
|
|
50
|
+
if (pid !== null) return pidIsAlive(pid) ? ageBand(session, age) : "offline";
|
|
51
|
+
return age > UNKNOWN_EXPIRY_MS ? "offline" : ageBand(session, age);
|
|
20
52
|
}
|
|
21
53
|
|
|
22
54
|
const sessionRecord = (input, now, generation) => validateRecord("session", {
|
|
@@ -30,6 +62,7 @@ const sessionRecord = (input, now, generation) => validateRecord("session", {
|
|
|
30
62
|
parentSessionId: input.parentSessionId ?? null,
|
|
31
63
|
checkoutRoot: input.checkoutRoot ?? null,
|
|
32
64
|
branch: input.branch ?? null,
|
|
65
|
+
pid: input.pid ?? null,
|
|
33
66
|
// Both default to the weaker reading. A session that declares nothing is a
|
|
34
67
|
// session nothing intercepts - an MCP client, or a CLI user - and claiming
|
|
35
68
|
// otherwise would promise enforcement that is not there.
|
|
@@ -50,7 +83,7 @@ const participantRecord = (input, now) => validateRecord("participant", {
|
|
|
50
83
|
});
|
|
51
84
|
|
|
52
85
|
export function createSessionService(ports) {
|
|
53
|
-
const { store, clock, ids } = ports;
|
|
86
|
+
const { store, clock, ids, pidIsAlive } = ports;
|
|
54
87
|
const workspaceOf = input => input.workspaceId ?? store.workspaceId;
|
|
55
88
|
|
|
56
89
|
async function locate(sessionId, workspaceId) {
|
|
@@ -66,9 +99,16 @@ export function createSessionService(ports) {
|
|
|
66
99
|
function assertReplaceable(existing, probe) {
|
|
67
100
|
if (existing.record.state === "closed") return;
|
|
68
101
|
// Presence staleness alone never replaces ownership: an idle-but-open
|
|
69
|
-
// session may resume at any moment
|
|
70
|
-
//
|
|
71
|
-
|
|
102
|
+
// session may resume at any moment, and a wrong "gone" verdict there
|
|
103
|
+
// self-corrects the moment that session next takes a turn. A wrong
|
|
104
|
+
// replacement does not self-correct - it takes the generation, and the
|
|
105
|
+
// original session's own heartbeats fail with CONFLICT from then on. So
|
|
106
|
+
// only a pid confirmed dead is authority to replace; "we cannot tell" -
|
|
107
|
+
// a session with no recorded pid - is never enough, however long the
|
|
108
|
+
// silence.
|
|
109
|
+
const live = probe ?? (record => (record.pid ?? null) === null
|
|
110
|
+
|| pidIsAlive(record.pid));
|
|
111
|
+
if (live(existing.record)) {
|
|
72
112
|
throw new AccError(EXIT.CONFLICT, "the session id is already live",
|
|
73
113
|
{ sessionId: existing.record.sessionId });
|
|
74
114
|
}
|
|
@@ -58,7 +58,7 @@ export function createGuardStateService(ports) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export function createStatusService(ports, sessions) {
|
|
61
|
-
const { store, clock } = ports;
|
|
61
|
+
const { store, clock, pidIsAlive } = ports;
|
|
62
62
|
|
|
63
63
|
async function collectStatus(input = {}) {
|
|
64
64
|
const workspaceId = input.workspaceId ?? store.workspaceId;
|
|
@@ -72,9 +72,17 @@ export function createStatusService(ports, sessions) {
|
|
|
72
72
|
? snapshot.sessions
|
|
73
73
|
: await store.ephemeral.list("session");
|
|
74
74
|
const intents = durable ? snapshot.intents : await store.ephemeral.list("intent");
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
// Classified once, and reused everywhere below. `classifySessionPresence` now
|
|
76
|
+
// asks `pidIsAlive`, a real `process.kill(pid, 0)` syscall for a session with
|
|
77
|
+
// a recorded pid, so it is no longer pure given `now` - a client can exit
|
|
78
|
+
// between two calls inside the same response, and re-reading the same
|
|
79
|
+
// session later in this function can then disagree with what an earlier
|
|
80
|
+
// reading already said. One reading, taken once, is what keeps `counts.live`,
|
|
81
|
+
// the roster filter, and each listed session's `presence` describing the
|
|
82
|
+
// same instant rather than three independent guesses at it.
|
|
83
|
+
const classified = sessionRecords
|
|
84
|
+
.map(session => ({ session, presence: classifySessionPresence(session, now, pidIsAlive) }));
|
|
85
|
+
const live = classified.filter(item => item.presence !== "offline");
|
|
78
86
|
const claims = snapshot.claims
|
|
79
87
|
.filter(claim => Date.parse(claim.expiresAt) > Date.parse(now));
|
|
80
88
|
|
|
@@ -87,10 +95,9 @@ export function createStatusService(ports, sessions) {
|
|
|
87
95
|
// the roster is where "which worktree was that agent in" is answered - so
|
|
88
96
|
// after a month of work this listed sixty entries for one live session.
|
|
89
97
|
// `acc status --all` is how the worktree-cleanup question is asked.
|
|
90
|
-
participants:
|
|
91
|
-
.filter(
|
|
92
|
-
|
|
93
|
-
.map(session => ({
|
|
98
|
+
participants: classified
|
|
99
|
+
.filter(item => input.all === true || item.presence !== "offline")
|
|
100
|
+
.map(({ session, presence }) => ({
|
|
94
101
|
sessionId: session.sessionId,
|
|
95
102
|
participantId: session.participantId,
|
|
96
103
|
harness: session.harness,
|
|
@@ -99,7 +106,7 @@ export function createStatusService(ports, sessions) {
|
|
|
99
106
|
branch: session.branch ?? null,
|
|
100
107
|
enforcement: session.enforcement ?? "advisory",
|
|
101
108
|
lifecycle: session.lifecycle ?? "manual",
|
|
102
|
-
presence
|
|
109
|
+
presence,
|
|
103
110
|
intent: intents.find(intent => intent.sessionId === session.sessionId)?.summary ?? null,
|
|
104
111
|
})),
|
|
105
112
|
workstreams: snapshot.workstreams.map(workstream => ({
|
|
@@ -127,7 +134,7 @@ export function createStatusService(ports, sessions) {
|
|
|
127
134
|
expiresAt: claim.expiresAt,
|
|
128
135
|
})),
|
|
129
136
|
attention: computeAttention(snapshot, { session: null,
|
|
130
|
-
participantId: input.participantId, now }),
|
|
137
|
+
participantId: input.participantId, now, pidIsAlive }),
|
|
131
138
|
counts: {
|
|
132
139
|
live: live.length,
|
|
133
140
|
stale: live.filter(item => item.presence === "stale").length,
|
|
@@ -154,11 +154,18 @@ function unansweredQuestions(snapshot, participantId, onlineParticipants) {
|
|
|
154
154
|
return items;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
function stalledRequests(snapshot, participantId, now) {
|
|
157
|
+
function stalledRequests(snapshot, participantId, now, pidIsAlive) {
|
|
158
|
+
// Classified once per session and reused below, for the same reason
|
|
159
|
+
// collectStatus takes one reading: classifySessionPresence calls pidIsAlive,
|
|
160
|
+
// a real process.kill(pid, 0) syscall for a session with a recorded pid, so
|
|
161
|
+
// it is not pure given `now` any more. A second classifying pass here could
|
|
162
|
+
// disagree with the first - a client exiting between them would leave `live`
|
|
163
|
+
// saying "online" while a freshly-computed `onlineParticipants` had already
|
|
164
|
+
// dropped it, inside one attention computation.
|
|
158
165
|
const live = new Map((snapshot.sessions ?? [])
|
|
159
|
-
.map(session => [session.sessionId, classifySessionPresence(session, now)]));
|
|
166
|
+
.map(session => [session.sessionId, classifySessionPresence(session, now, pidIsAlive)]));
|
|
160
167
|
const onlineParticipants = new Set((snapshot.sessions ?? [])
|
|
161
|
-
.filter(session =>
|
|
168
|
+
.filter(session => live.get(session.sessionId) === "online")
|
|
162
169
|
.map(session => session.participantId));
|
|
163
170
|
const goingNowhere = task => {
|
|
164
171
|
// Taken by someone who has gone quiet.
|
|
@@ -191,20 +198,28 @@ function coordinatorGaps(snapshot) {
|
|
|
191
198
|
sourceId: workstream.workstreamId, summary: workstream.title }));
|
|
192
199
|
}
|
|
193
200
|
|
|
194
|
-
export function computeAttention(snapshot, { session, participantId, now }) {
|
|
201
|
+
export function computeAttention(snapshot, { session, participantId, now, pidIsAlive }) {
|
|
202
|
+
// Required unconditionally, not only when there happen to be sessions to
|
|
203
|
+
// classify: `stalledRequests` reaches `classifySessionPresence` only inside a
|
|
204
|
+
// map/filter over `snapshot.sessions`, so an empty or absent roster let a
|
|
205
|
+
// missing probe through with nothing to trip over it - the same silent pass
|
|
206
|
+
// the classifier's own required parameter exists to close, one layer up.
|
|
207
|
+
if (typeof pidIsAlive !== "function") {
|
|
208
|
+
throw new AccError(EXIT.USAGE, "computeAttention requires a pidIsAlive probe", {});
|
|
209
|
+
}
|
|
195
210
|
return [
|
|
196
211
|
...directRequests(snapshot, participantId),
|
|
197
212
|
...claimConflicts(snapshot, session, now),
|
|
198
213
|
...expiredClaims(snapshot, session, now),
|
|
199
214
|
...unblockedTasks(snapshot, session, participantId),
|
|
200
215
|
...coordinatorGaps(snapshot),
|
|
201
|
-
...stalledRequests(snapshot, participantId, now),
|
|
216
|
+
...stalledRequests(snapshot, participantId, now, pidIsAlive),
|
|
202
217
|
].sort((left, right) => left.priority - right.priority
|
|
203
218
|
|| left.sourceId.localeCompare(right.sourceId));
|
|
204
219
|
}
|
|
205
220
|
|
|
206
221
|
export function createSyncService(ports, sessions) {
|
|
207
|
-
const { store, clock } = ports;
|
|
222
|
+
const { store, clock, pidIsAlive } = ports;
|
|
208
223
|
|
|
209
224
|
/**
|
|
210
225
|
* Any session may request the full Workspace scope. Peer equality is a
|
|
@@ -240,7 +255,7 @@ export function createSyncService(ports, sessions) {
|
|
|
240
255
|
const page = await store.eventsSince(workspaceId, input.cursor ?? null,
|
|
241
256
|
input.limit ?? DEFAULT_LIMIT);
|
|
242
257
|
const attention = computeAttention(snapshot, { session,
|
|
243
|
-
participantId: session?.participantId ?? input.participantId, now });
|
|
258
|
+
participantId: session?.participantId ?? input.participantId, now, pidIsAlive });
|
|
244
259
|
|
|
245
260
|
const roster = snapshot.sessions.map(item => ({
|
|
246
261
|
sessionId: item.sessionId,
|
|
@@ -248,7 +263,7 @@ export function createSyncService(ports, sessions) {
|
|
|
248
263
|
parentSessionId: item.parentSessionId,
|
|
249
264
|
harness: item.harness,
|
|
250
265
|
branch: item.branch ?? null,
|
|
251
|
-
presence: classifySessionPresence(item, now),
|
|
266
|
+
presence: classifySessionPresence(item, now, pidIsAlive),
|
|
252
267
|
}));
|
|
253
268
|
|
|
254
269
|
// Solo zero-overhead: one live session, no claims and
|
|
@@ -84,7 +84,7 @@ export function writeTask(tx, { input, session, workspaceId, now, ids }) {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
export function createTaskService(ports, workstreams) {
|
|
87
|
-
const { store, clock, ids } = ports;
|
|
87
|
+
const { store, clock, ids, pidIsAlive } = ports;
|
|
88
88
|
|
|
89
89
|
async function createTask(input) {
|
|
90
90
|
const session = await workstreams.requireOpenSession(input, "create a task");
|
|
@@ -120,13 +120,19 @@ export function createTaskService(ports, workstreams) {
|
|
|
120
120
|
&& existing.assigneeSessionId !== session.sessionId) {
|
|
121
121
|
// A holder that is gone is not a holder. Closing a session hands its
|
|
122
122
|
// work back, so this is the crash case: no session end ever arrived and
|
|
123
|
-
// presence has decayed.
|
|
124
|
-
//
|
|
125
|
-
//
|
|
123
|
+
// presence has decayed. `stale` alone does not release it - the same
|
|
124
|
+
// rule claims follow, because an idle agent may be thinking rather than
|
|
125
|
+
// dead - and taking it over needs `force`. `offline` needs no force at
|
|
126
|
+
// all, silence-derived cases included: a session with no recorded pid,
|
|
127
|
+
// quiet past the unknown floor, reads offline exactly like one whose
|
|
128
|
+
// pid is confirmed dead, and either way the task is simply taken. That
|
|
129
|
+
// is acceptable here where it would not be for a session id: the
|
|
130
|
+
// participant guard below still refuses a different participant, and
|
|
131
|
+
// taking a task back is reversible where replacing an id is not.
|
|
126
132
|
const holder = tx.get("session", existing.assigneeSessionId);
|
|
127
133
|
const presence = holder === null
|
|
128
134
|
? "offline"
|
|
129
|
-
: classifySessionPresence(holder, now);
|
|
135
|
+
: classifySessionPresence(holder, now, pidIsAlive);
|
|
130
136
|
if (presence === "online") {
|
|
131
137
|
throw new AccError(EXIT.CONFLICT, "the task already has an assignee",
|
|
132
138
|
{ taskId: input.taskId, assigneeSessionId: existing.assigneeSessionId });
|
|
@@ -8,7 +8,7 @@ import { classifySessionPresence } from "./sessions.mjs";
|
|
|
8
8
|
// coordinator lease, and the coordinator plans - it is never the transport,
|
|
9
9
|
// the durable owner, or an information gatekeeper.
|
|
10
10
|
export function createWorkstreamService(ports, sessions) {
|
|
11
|
-
const { store, clock, ids } = ports;
|
|
11
|
+
const { store, clock, ids, pidIsAlive } = ports;
|
|
12
12
|
|
|
13
13
|
async function requireOpenSession(input, action) {
|
|
14
14
|
const existing = await sessions.locateSession(input.sessionId, input.workspaceId);
|
|
@@ -63,7 +63,7 @@ export function createWorkstreamService(ports, sessions) {
|
|
|
63
63
|
if (held !== null && held !== session.sessionId) {
|
|
64
64
|
const holder = snapshot.sessions.find(item => item.sessionId === held);
|
|
65
65
|
const presence = holder === undefined ? "offline"
|
|
66
|
-
: classifySessionPresence(holder, now);
|
|
66
|
+
: classifySessionPresence(holder, now, pidIsAlive);
|
|
67
67
|
// A coordinator lease is replaced only when the holder is genuinely
|
|
68
68
|
// gone or policy says so - not because a peer would like the role.
|
|
69
69
|
if (presence !== "offline" && input.authority !== "human"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
// Deep enough for a client that wraps its hook in a shell and a launcher, short
|
|
4
|
+
// enough that a table which disagrees with itself cannot spin.
|
|
5
|
+
const MAX_HOPS = 16;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The pid of the client this hook is running for, or null when nobody knows.
|
|
9
|
+
*
|
|
10
|
+
* The hook is not the client's child. Measured on macOS, a process spawned by
|
|
11
|
+
* Claude Code has parent `/bin/zsh` and grandparent `claude`, so `process.ppid`
|
|
12
|
+
* names a shell that dies with the hook. Walking until the adapter's own
|
|
13
|
+
* declared binary appears is what makes the answer specific rather than a guess
|
|
14
|
+
* about which ancestors are "real".
|
|
15
|
+
*
|
|
16
|
+
* Null is a first-class answer: it means judge this session by age alone.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveClientPid({ table, from, command, maxHops = MAX_HOPS }) {
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
let current = from;
|
|
21
|
+
for (let hop = 0; hop < maxHops; hop += 1) {
|
|
22
|
+
const entry = table.get(current);
|
|
23
|
+
if (entry === undefined || seen.has(current)) return null;
|
|
24
|
+
seen.add(current);
|
|
25
|
+
// `ps` reports some entries bare (`claude`) and some with a path
|
|
26
|
+
// (`/bin/zsh`), so the comparison has to be on the basename.
|
|
27
|
+
if (path.basename(entry.comm) === command) return current;
|
|
28
|
+
if (entry.ppid === current || entry.ppid <= 1) return null;
|
|
29
|
+
current = entry.ppid;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const run = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
// A hook runs in front of someone's turn. Reading the table is worth a few
|
|
7
|
+
// hundred milliseconds once per session and nothing at all if it is slow.
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 1_000;
|
|
9
|
+
|
|
10
|
+
const LINE = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every process on this machine, as pid -> parent and executable.
|
|
14
|
+
*
|
|
15
|
+
* Returns an empty map rather than throwing when the platform has no `ps`
|
|
16
|
+
* (Windows) or the call fails. An empty table resolves no client, which is the
|
|
17
|
+
* "nobody knows" answer the caller already handles.
|
|
18
|
+
*/
|
|
19
|
+
export async function readProcessTable({ run: exec = run,
|
|
20
|
+
timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
21
|
+
try {
|
|
22
|
+
const { stdout } = await exec("ps", ["-o", "pid=,ppid=,comm=", "-A"],
|
|
23
|
+
{ timeout: timeoutMs });
|
|
24
|
+
const table = new Map();
|
|
25
|
+
for (const line of stdout.split("\n")) {
|
|
26
|
+
const match = LINE.exec(line);
|
|
27
|
+
if (match === null) continue;
|
|
28
|
+
table.set(Number(match[1]), { ppid: Number(match[2]), comm: match[3] });
|
|
29
|
+
}
|
|
30
|
+
return table;
|
|
31
|
+
} catch {
|
|
32
|
+
return new Map();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -10,6 +10,9 @@ import { openFilesystemStore } from "@agents-can-communicate/storage-filesystem"
|
|
|
10
10
|
import { createGitProbe, discoverWorkspace, platformDataHome, runtimePaths }
|
|
11
11
|
from "@agents-can-communicate/cli";
|
|
12
12
|
|
|
13
|
+
import { resolveClientPid } from "./client-pid.mjs";
|
|
14
|
+
import { readProcessTable as defaultReadProcessTable } from "./process-table.mjs";
|
|
15
|
+
|
|
13
16
|
// A hook runs in front of the user's turn, so it gets a hard ceiling. Better to
|
|
14
17
|
// let a call through than to make someone's session sit waiting on us.
|
|
15
18
|
const DEFAULT_BUDGET_MS = 5_000;
|
|
@@ -154,8 +157,14 @@ async function openContext({ cwd, dataHome, runtime, env }) {
|
|
|
154
157
|
}
|
|
155
158
|
|
|
156
159
|
const HANDLERS = {
|
|
157
|
-
async sessionStart({ event, context, adapter, adapterId, paths }) {
|
|
160
|
+
async sessionStart({ event, context, adapter, adapterId, paths, readProcessTable }) {
|
|
158
161
|
const capabilities = adapter.capabilities ?? {};
|
|
162
|
+
// Once per session, never per turn. A client that cannot be found yields
|
|
163
|
+
// null, and the session is then judged by age alone - which is exactly the
|
|
164
|
+
// behaviour every session had before this existed.
|
|
165
|
+
const command = adapter.client?.command ?? null;
|
|
166
|
+
const pid = command === null ? null
|
|
167
|
+
: resolveClientPid({ table: await readProcessTable(), from: process.pid, command });
|
|
159
168
|
const session = await context.service.openSession({
|
|
160
169
|
workspaceId: context.descriptor.id,
|
|
161
170
|
participantId: participantFor(adapterId, event.sessionId, context.env),
|
|
@@ -171,6 +180,7 @@ const HANDLERS = {
|
|
|
171
180
|
// Which checkout this agent is in. One workspace spans every worktree of
|
|
172
181
|
// a repository, so this is the only thing that distinguishes them.
|
|
173
182
|
checkoutRoot: context.descriptor.git?.worktreeRoot ?? context.descriptor.roots[0],
|
|
183
|
+
pid,
|
|
174
184
|
branch: context.descriptor.git?.branch ?? null,
|
|
175
185
|
descriptor: context.descriptor,
|
|
176
186
|
});
|
|
@@ -339,7 +349,8 @@ const HANDLERS = {
|
|
|
339
349
|
* only thing this function refuses to do is fail closed.
|
|
340
350
|
*/
|
|
341
351
|
export async function runHook({ adapterId, payload, adapters, dataHome, env,
|
|
342
|
-
runtime = defaultRuntime(), budgetMs = DEFAULT_BUDGET_MS
|
|
352
|
+
runtime = defaultRuntime(), budgetMs = DEFAULT_BUDGET_MS,
|
|
353
|
+
readProcessTable = defaultReadProcessTable }) {
|
|
343
354
|
const result = { stdout: "", exitCode: 0, decision: "allow", sessions: [] };
|
|
344
355
|
let timer = null;
|
|
345
356
|
try {
|
|
@@ -354,7 +365,8 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
|
|
|
354
365
|
const handler = HANDLERS[event.kind];
|
|
355
366
|
const work = handler === undefined
|
|
356
367
|
? Promise.resolve({})
|
|
357
|
-
: handler({ event, context, adapter, adapterId, binding, paths: context.paths
|
|
368
|
+
: handler({ event, context, adapter, adapterId, binding, paths: context.paths,
|
|
369
|
+
readProcessTable });
|
|
358
370
|
|
|
359
371
|
// The loser of a race is not cancelled, so the timer is cleared explicitly:
|
|
360
372
|
// an outstanding one keeps the process alive long past its answer.
|
|
@@ -2,7 +2,7 @@ import { AccError, EXIT } from "./errors.mjs";
|
|
|
2
2
|
import { flag, id, invalid, listOf, nullable, oneOf, plainObject, positiveInteger,
|
|
3
3
|
resourceUri, sequence, text, timestamp } from "./fields.mjs";
|
|
4
4
|
|
|
5
|
-
export const SCHEMA_VERSION =
|
|
5
|
+
export const SCHEMA_VERSION = 2;
|
|
6
6
|
|
|
7
7
|
const line = text();
|
|
8
8
|
const prose = text({ max: 4000, multiline: true });
|
|
@@ -78,6 +78,10 @@ const RECORDS = Object.freeze({
|
|
|
78
78
|
session: { sessionId: id, participantId: id, workspaceId: id, generation: id,
|
|
79
79
|
harness: line, state: oneOf("open", "closed"), parentSessionId: nullable(id),
|
|
80
80
|
checkoutRoot: nullable(line), branch: nullable(line),
|
|
81
|
+
// The process behind this session, when it can be named. Null means nobody
|
|
82
|
+
// knows - no process table, or an ancestry that did not resolve - and is
|
|
83
|
+
// read as "judge this one by age alone", never as "dead".
|
|
84
|
+
pid: nullable(positiveInteger),
|
|
81
85
|
enforcement: oneOf("guarded", "advisory"), lifecycle: oneOf("managed", "manual"),
|
|
82
86
|
heartbeatCadenceMs: positiveInteger, startedAt: timestamp, heartbeatAt: timestamp },
|
|
83
87
|
|
|
@@ -4,7 +4,7 @@ import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protoc
|
|
|
4
4
|
|
|
5
5
|
import { encode, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
|
|
6
6
|
|
|
7
|
-
export const STORE_VERSION =
|
|
7
|
+
export const STORE_VERSION = 2;
|
|
8
8
|
|
|
9
9
|
export function identityPath(paths) {
|
|
10
10
|
return path.join(paths.root, "protocol.json");
|