talking-stick 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/guardian.js +39 -3
- package/dist/cli/output.js +1 -1
- package/dist/cli/turn-commands.js +8 -4
- package/dist/db.js +18 -0
- package/dist/identity.js +24 -2
- package/dist/instructions.js +1 -1
- package/dist/service.js +162 -12
- package/docs/plans/2026-05-12-zombie-session-eviction.md +157 -0
- package/docs/releases/0.4.5.md +22 -0
- package/docs/releases/0.4.6.md +20 -0
- package/package.json +1 -1
- package/skills/talking-stick/SKILL.md +8 -1
package/dist/cli/guardian.js
CHANGED
|
@@ -3,17 +3,24 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { createSystemProcessInspector, deriveHumanCliIdentity, isProtocolError, terminateKnownProcess } from "../index.js";
|
|
6
|
-
import { parseRequiredInteger, requireStringOption } from "./parser.js";
|
|
6
|
+
import { getStringOption, parseRequiredInteger, requireStringOption } from "./parser.js";
|
|
7
7
|
import { createRuntime } from "./runtime.js";
|
|
8
8
|
const GUARD_READY = "READY";
|
|
9
9
|
const GUARD_READY_TIMEOUT_MS = 10_000;
|
|
10
10
|
const STALE_GUARD_ERRORS = new Set(["stale_lease", "turn_mismatch", "room_not_found"]);
|
|
11
11
|
export async function runGuardCommand(parsed) {
|
|
12
|
-
const
|
|
12
|
+
const baseIdentity = deriveHumanCliIdentity({
|
|
13
13
|
agentId: requireStringOption(parsed, "agent"),
|
|
14
14
|
displayName: requireStringOption(parsed, "agent").replace(/^human:/, ""),
|
|
15
15
|
sessionKind: "human_guardian"
|
|
16
16
|
});
|
|
17
|
+
const identity = {
|
|
18
|
+
...baseIdentity,
|
|
19
|
+
process_metadata: {
|
|
20
|
+
...baseIdentity.process_metadata,
|
|
21
|
+
...parseHarnessMetadataOptions(parsed)
|
|
22
|
+
}
|
|
23
|
+
};
|
|
17
24
|
const runtime = createRuntime();
|
|
18
25
|
try {
|
|
19
26
|
const joined = runtime.commands.joinPath(identity, {
|
|
@@ -51,6 +58,7 @@ export async function runGuardCommand(parsed) {
|
|
|
51
58
|
}
|
|
52
59
|
export async function spawnGuardian(input) {
|
|
53
60
|
const self = resolveSelfSpawn(input.cliEntryUrl);
|
|
61
|
+
const harnessArgs = serializeHarnessMetadataOptions(input.processMetadata);
|
|
54
62
|
const child = spawn(self.command, [
|
|
55
63
|
...self.args,
|
|
56
64
|
"guard",
|
|
@@ -63,7 +71,8 @@ export async function spawnGuardian(input) {
|
|
|
63
71
|
"--lease-id",
|
|
64
72
|
input.leaseId,
|
|
65
73
|
"--turn-id",
|
|
66
|
-
String(input.turnId)
|
|
74
|
+
String(input.turnId),
|
|
75
|
+
...harnessArgs
|
|
67
76
|
], {
|
|
68
77
|
detached: true,
|
|
69
78
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -105,6 +114,33 @@ export async function spawnGuardian(input) {
|
|
|
105
114
|
});
|
|
106
115
|
});
|
|
107
116
|
}
|
|
117
|
+
function parseHarnessMetadataOptions(parsed) {
|
|
118
|
+
const harnessPid = getStringOption(parsed, "harness-pid");
|
|
119
|
+
return {
|
|
120
|
+
harness_name: getStringOption(parsed, "harness-name") ?? null,
|
|
121
|
+
harness_session_id: getStringOption(parsed, "harness-session-id") ?? null,
|
|
122
|
+
harness_host_id: getStringOption(parsed, "harness-host-id") ?? null,
|
|
123
|
+
harness_pid: harnessPid ? Number.parseInt(harnessPid, 10) : null,
|
|
124
|
+
harness_process_started_at: getStringOption(parsed, "harness-process-started-at") ?? null
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function serializeHarnessMetadataOptions(metadata) {
|
|
128
|
+
const args = [];
|
|
129
|
+
appendOption(args, "harness-name", metadata?.harness_name);
|
|
130
|
+
appendOption(args, "harness-session-id", metadata?.harness_session_id);
|
|
131
|
+
appendOption(args, "harness-host-id", metadata?.harness_host_id);
|
|
132
|
+
appendOption(args, "harness-pid", metadata?.harness_pid === null || metadata?.harness_pid === undefined
|
|
133
|
+
? null
|
|
134
|
+
: String(metadata.harness_pid));
|
|
135
|
+
appendOption(args, "harness-process-started-at", metadata?.harness_process_started_at);
|
|
136
|
+
return args;
|
|
137
|
+
}
|
|
138
|
+
function appendOption(args, key, value) {
|
|
139
|
+
if (!value) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
args.push(`--${key}`, value);
|
|
143
|
+
}
|
|
108
144
|
function resolveSelfSpawn(cliEntryUrl) {
|
|
109
145
|
const scriptPath = fileURLToPath(cliEntryUrl);
|
|
110
146
|
if (scriptPath.endsWith(".ts")) {
|
package/dist/cli/output.js
CHANGED
|
@@ -61,7 +61,7 @@ export function formatWaitResult(result) {
|
|
|
61
61
|
return `Not your turn — turn ${result.turn_id ?? "?"} is reserved for ${result.reserved_for}${deadline}.`;
|
|
62
62
|
}
|
|
63
63
|
if (result.reason === "auto_claim_disabled") {
|
|
64
|
-
return "Parked —
|
|
64
|
+
return result.hint ?? "Parked — idle room left unclaimed.";
|
|
65
65
|
}
|
|
66
66
|
return "Not your turn yet.";
|
|
67
67
|
}
|
|
@@ -34,7 +34,8 @@ export async function handleWaitCommand(runtime, parsed, isTry, cliEntryUrl) {
|
|
|
34
34
|
roomId: joined.room_id,
|
|
35
35
|
leaseId: waitResult.lease_id,
|
|
36
36
|
turnId: waitResult.turn_id,
|
|
37
|
-
cliEntryUrl
|
|
37
|
+
cliEntryUrl,
|
|
38
|
+
processMetadata: identity.process_metadata
|
|
38
39
|
});
|
|
39
40
|
upsertCliSession(sessionPath, {
|
|
40
41
|
agent_id: identity.agent_id,
|
|
@@ -71,7 +72,8 @@ export async function handleWaitCommand(runtime, parsed, isTry, cliEntryUrl) {
|
|
|
71
72
|
roomId: joined.room_id,
|
|
72
73
|
leaseId: waitResult.lease_id,
|
|
73
74
|
turnId: waitResult.turn_id,
|
|
74
|
-
cliEntryUrl
|
|
75
|
+
cliEntryUrl,
|
|
76
|
+
processMetadata: identity.process_metadata
|
|
75
77
|
});
|
|
76
78
|
upsertCliSession(resolveCliSessionPath(), {
|
|
77
79
|
agent_id: identity.agent_id,
|
|
@@ -110,7 +112,8 @@ export async function handleTakeCommand(runtime, parsed, cliEntryUrl) {
|
|
|
110
112
|
roomId: joined.room_id,
|
|
111
113
|
leaseId: availability.lease_id,
|
|
112
114
|
turnId: availability.turn_id,
|
|
113
|
-
cliEntryUrl
|
|
115
|
+
cliEntryUrl,
|
|
116
|
+
processMetadata: identity.process_metadata
|
|
114
117
|
});
|
|
115
118
|
upsertCliSession(resolveCliSessionPath(), {
|
|
116
119
|
agent_id: identity.agent_id,
|
|
@@ -144,7 +147,8 @@ export async function handleTakeCommand(runtime, parsed, cliEntryUrl) {
|
|
|
144
147
|
roomId: joined.room_id,
|
|
145
148
|
leaseId: result.lease_id,
|
|
146
149
|
turnId: result.turn_id,
|
|
147
|
-
cliEntryUrl
|
|
150
|
+
cliEntryUrl,
|
|
151
|
+
processMetadata: identity.process_metadata
|
|
148
152
|
});
|
|
149
153
|
upsertCliSession(resolveCliSessionPath(), {
|
|
150
154
|
agent_id: identity.agent_id,
|
package/dist/db.js
CHANGED
|
@@ -103,6 +103,24 @@ const migrations = [
|
|
|
103
103
|
up: `
|
|
104
104
|
ALTER TABLE room_events ADD COLUMN payload_json TEXT;
|
|
105
105
|
`
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: 6,
|
|
109
|
+
name: "room_member_harness_instance_metadata",
|
|
110
|
+
up: `
|
|
111
|
+
ALTER TABLE room_members ADD COLUMN harness_name TEXT;
|
|
112
|
+
ALTER TABLE room_members ADD COLUMN harness_session_id TEXT;
|
|
113
|
+
ALTER TABLE room_members ADD COLUMN harness_host_id TEXT;
|
|
114
|
+
ALTER TABLE room_members ADD COLUMN harness_pid INTEGER;
|
|
115
|
+
ALTER TABLE room_members ADD COLUMN harness_process_started_at TEXT;
|
|
116
|
+
`
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: 7,
|
|
120
|
+
name: "room_member_last_park_hint_event_seq",
|
|
121
|
+
up: `
|
|
122
|
+
ALTER TABLE room_members ADD COLUMN last_park_hint_event_seq INTEGER;
|
|
123
|
+
`
|
|
106
124
|
}
|
|
107
125
|
];
|
|
108
126
|
export function resolveDatabasePath(options = {}) {
|
package/dist/identity.js
CHANGED
|
@@ -42,6 +42,7 @@ export function deriveMcpHarnessIdentity(options = {}) {
|
|
|
42
42
|
if (signal) {
|
|
43
43
|
const processRef = resolveSignalProcessRef(signal, parentPid, parentInspection, inspector);
|
|
44
44
|
const sessionId = resolveHarnessSessionId(signal, env, processRef.pid, processRef.inspection, username, hostId, inspector);
|
|
45
|
+
const harnessProcess = resolveHarnessProcessRef(signal, processRef, inspector);
|
|
45
46
|
const agentId = options.agentId ?? harnessAgentId(signal.harness, sessionId, hostId, username);
|
|
46
47
|
return {
|
|
47
48
|
agent_id: agentId,
|
|
@@ -50,7 +51,12 @@ export function deriveMcpHarnessIdentity(options = {}) {
|
|
|
50
51
|
pid: processRef.pid,
|
|
51
52
|
process_started_at: processRef.inspection?.startTime ?? null,
|
|
52
53
|
session_kind: "mcp_harness",
|
|
53
|
-
display_name: signal.harness
|
|
54
|
+
display_name: signal.harness,
|
|
55
|
+
harness_name: signal.harness,
|
|
56
|
+
harness_session_id: sessionId,
|
|
57
|
+
harness_host_id: hostId,
|
|
58
|
+
harness_pid: harnessProcess.pid,
|
|
59
|
+
harness_process_started_at: harnessProcess.startTime
|
|
54
60
|
}
|
|
55
61
|
};
|
|
56
62
|
}
|
|
@@ -105,6 +111,7 @@ export function deriveHarnessCliIdentity(options = {}) {
|
|
|
105
111
|
const username = options.username ?? safeUsername();
|
|
106
112
|
const sessionId = resolveHarnessSessionId(signal, env, processRef.pid, processRef.inspection, username, hostId, inspector);
|
|
107
113
|
const agentId = options.agentId ?? harnessAgentId(signal.harness, sessionId, hostId, username);
|
|
114
|
+
const harnessProcess = resolveHarnessProcessRef(signal, processRef, inspector);
|
|
108
115
|
return {
|
|
109
116
|
agent_id: agentId,
|
|
110
117
|
process_metadata: {
|
|
@@ -112,7 +119,12 @@ export function deriveHarnessCliIdentity(options = {}) {
|
|
|
112
119
|
pid: processRef.pid,
|
|
113
120
|
process_started_at: processRef.inspection?.startTime ?? null,
|
|
114
121
|
session_kind: "harness_cli",
|
|
115
|
-
display_name: options.displayName ?? signal.harness
|
|
122
|
+
display_name: options.displayName ?? signal.harness,
|
|
123
|
+
harness_name: signal.harness,
|
|
124
|
+
harness_session_id: sessionId,
|
|
125
|
+
harness_host_id: hostId,
|
|
126
|
+
harness_pid: harnessProcess.pid,
|
|
127
|
+
harness_process_started_at: harnessProcess.startTime
|
|
116
128
|
}
|
|
117
129
|
};
|
|
118
130
|
}
|
|
@@ -139,6 +151,16 @@ function resolveHarnessSessionId(signal, env, parentPid, parentInspection, usern
|
|
|
139
151
|
}
|
|
140
152
|
return `userhost:${sanitizeIdentityComponent(username)}@${hostId}`;
|
|
141
153
|
}
|
|
154
|
+
function resolveHarnessProcessRef(signal, processRef, inspector) {
|
|
155
|
+
const harnessRoot = findHarnessRootInAncestry(signal.harness, processRef.pid, processRef.inspection, inspector);
|
|
156
|
+
if (harnessRoot) {
|
|
157
|
+
return harnessRoot;
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
pid: processRef.pid,
|
|
161
|
+
startTime: processRef.inspection?.startTime ?? null
|
|
162
|
+
};
|
|
163
|
+
}
|
|
142
164
|
// Walks the process ancestry (inclusive of startPid) looking for the deepest
|
|
143
165
|
// process whose command matches the named harness. Anchoring session id to
|
|
144
166
|
// that root keeps `tt` invocations stable whether they're spawned directly
|
package/dist/instructions.js
CHANGED
|
@@ -6,7 +6,7 @@ import { resolveContextPath } from "./path-resolution.js";
|
|
|
6
6
|
export const DEFAULT_MAX_INSTRUCTION_FILE_BYTES = 256 * 1024;
|
|
7
7
|
export const DEFAULT_INSTRUCTIONS_MARKDOWN = `# Talking Stick collaboration instructions
|
|
8
8
|
|
|
9
|
-
Keep using Talking Stick until the shared task is done. After releasing or handing off, re-enter the wait loop by default. Prefer continued action unless the task is complete or the operator explicitly redirects or stops the room. If
|
|
9
|
+
Keep using Talking Stick until the shared task is done. After releasing or handing off, re-enter the wait loop by default. Prefer continued action unless the task is complete or the operator explicitly redirects or stops the room. If a handoff, message, or operator instruction leaves review, release, or other work pending, use normal \`tt wait --json\`; do not park. Use \`tt wait --park --json\` only for passive standby when no task is pending and you are blocked on operator input or an external signal.
|
|
10
10
|
|
|
11
11
|
On freshly invoked multi-agent tasks, give peers a short window to join before deciding you are alone. Use a normal wait timeout or spend about a minute on read-only repo orientation while other harnesses appear.
|
|
12
12
|
|
package/dist/service.js
CHANGED
|
@@ -15,6 +15,7 @@ const KNOWN_EVENT_TYPES = [
|
|
|
15
15
|
"takeover",
|
|
16
16
|
"close",
|
|
17
17
|
"kick",
|
|
18
|
+
"session_superseded",
|
|
18
19
|
"message_sent"
|
|
19
20
|
];
|
|
20
21
|
export class TalkingStickService {
|
|
@@ -66,7 +67,11 @@ export class TalkingStickService {
|
|
|
66
67
|
return withImmediateTransaction(this.db, () => {
|
|
67
68
|
const roomSelection = this.findOrCreateRoomForJoin(resolved, input.force_new === true, timestamp);
|
|
68
69
|
this.upsertMember(roomSelection.room.room_id, input.agent_id, timestamp, input.process_metadata);
|
|
70
|
+
const supersededAgentIds = this.retireSupersededHarnessSessions(roomSelection.room.room_id, input.agent_id, normalizeProcessMetadata(input.process_metadata), timestamp);
|
|
69
71
|
const freshRoom = this.requireRoom(roomSelection.room.room_id);
|
|
72
|
+
const warning = joinWarnings(roomSelection.warning, supersededAgentIds.length > 0
|
|
73
|
+
? `Superseded previous harness session(s): ${supersededAgentIds.join(", ")}.`
|
|
74
|
+
: undefined);
|
|
70
75
|
return {
|
|
71
76
|
agent_id: input.agent_id,
|
|
72
77
|
room_id: freshRoom.room_id,
|
|
@@ -74,7 +79,7 @@ export class TalkingStickService {
|
|
|
74
79
|
requested_path: resolved.requested_path,
|
|
75
80
|
workspace_root: resolved.workspace_root,
|
|
76
81
|
joined_existing_room: roomSelection.joinedExistingRoom,
|
|
77
|
-
warning
|
|
82
|
+
warning,
|
|
78
83
|
policy: { ...this.policy },
|
|
79
84
|
room_state: this.mapRoom(this.inspectRoom(freshRoom, now), now),
|
|
80
85
|
handoff_template: handoffTemplate()
|
|
@@ -233,7 +238,9 @@ export class TalkingStickService {
|
|
|
233
238
|
while (true) {
|
|
234
239
|
this.warmRoomTurnLiveness(input.room_id);
|
|
235
240
|
const result = withImmediateTransaction(this.db, () => this.waitForTurnOnce(input));
|
|
236
|
-
if (result.status !== "not_yet" ||
|
|
241
|
+
if (result.status !== "not_yet" ||
|
|
242
|
+
result.reason === "auto_claim_disabled" ||
|
|
243
|
+
Date.now() >= deadline) {
|
|
237
244
|
return result;
|
|
238
245
|
}
|
|
239
246
|
const remainingMs = deadline - Date.now();
|
|
@@ -658,6 +665,24 @@ export class TalkingStickService {
|
|
|
658
665
|
if (!room.owner && !room.reserved_for) {
|
|
659
666
|
const autoClaim = input.auto_claim ?? true;
|
|
660
667
|
if (!autoClaim) {
|
|
668
|
+
if (room.pending_handoff_event_seq) {
|
|
669
|
+
const member = this.getMember(input.room_id, input.agent_id);
|
|
670
|
+
const alreadyHinted = member?.last_park_hint_event_seq === room.pending_handoff_event_seq;
|
|
671
|
+
if (!alreadyHinted) {
|
|
672
|
+
this.recordParkHint(input.room_id, input.agent_id, room.pending_handoff_event_seq);
|
|
673
|
+
return {
|
|
674
|
+
status: "not_yet",
|
|
675
|
+
room_state: inspection.state,
|
|
676
|
+
turn_id: room.turn_id,
|
|
677
|
+
current_owner: room.owner ?? undefined,
|
|
678
|
+
reserved_for: room.reserved_for ?? undefined,
|
|
679
|
+
lease_expires_at: room.lease_expires_at ?? undefined,
|
|
680
|
+
claim_expires_at: room.claim_expires_at ?? undefined,
|
|
681
|
+
reason: "auto_claim_disabled",
|
|
682
|
+
hint: "A pending handoff is waiting in this idle room, but park mode does not auto-claim. Run `tt wait --json` to pick it up, or ask for an explicit assignment."
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
}
|
|
661
686
|
return {
|
|
662
687
|
status: "not_yet",
|
|
663
688
|
room_state: inspection.state,
|
|
@@ -665,8 +690,7 @@ export class TalkingStickService {
|
|
|
665
690
|
current_owner: room.owner ?? undefined,
|
|
666
691
|
reserved_for: room.reserved_for ?? undefined,
|
|
667
692
|
lease_expires_at: room.lease_expires_at ?? undefined,
|
|
668
|
-
claim_expires_at: room.claim_expires_at ?? undefined
|
|
669
|
-
reason: "auto_claim_disabled"
|
|
693
|
+
claim_expires_at: room.claim_expires_at ?? undefined
|
|
670
694
|
};
|
|
671
695
|
}
|
|
672
696
|
if (this.shouldDeferIdleClaim(room, input.agent_id, now)) {
|
|
@@ -855,10 +879,15 @@ export class TalkingStickService {
|
|
|
855
879
|
pid = ?,
|
|
856
880
|
process_started_at = ?,
|
|
857
881
|
session_kind = ?,
|
|
858
|
-
display_name =
|
|
882
|
+
display_name = ?,
|
|
883
|
+
harness_name = ?,
|
|
884
|
+
harness_session_id = ?,
|
|
885
|
+
harness_host_id = ?,
|
|
886
|
+
harness_pid = ?,
|
|
887
|
+
harness_process_started_at = ?
|
|
859
888
|
WHERE room_id = ? AND agent_id = ?
|
|
860
889
|
`)
|
|
861
|
-
.run(timestamp, timestamp, mergedMetadata.host_id, mergedMetadata.pid, mergedMetadata.process_started_at, mergedMetadata.session_kind, mergedMetadata.display_name, roomId, agentId);
|
|
890
|
+
.run(timestamp, timestamp, mergedMetadata.host_id, mergedMetadata.pid, mergedMetadata.process_started_at, mergedMetadata.session_kind, mergedMetadata.display_name, mergedMetadata.harness_name, mergedMetadata.harness_session_id, mergedMetadata.harness_host_id, mergedMetadata.harness_pid, mergedMetadata.harness_process_started_at, roomId, agentId);
|
|
862
891
|
return;
|
|
863
892
|
}
|
|
864
893
|
const nextOrdinal = this.db
|
|
@@ -878,11 +907,16 @@ export class TalkingStickService {
|
|
|
878
907
|
pid,
|
|
879
908
|
process_started_at,
|
|
880
909
|
session_kind,
|
|
881
|
-
display_name
|
|
910
|
+
display_name,
|
|
911
|
+
harness_name,
|
|
912
|
+
harness_session_id,
|
|
913
|
+
harness_host_id,
|
|
914
|
+
harness_pid,
|
|
915
|
+
harness_process_started_at
|
|
882
916
|
)
|
|
883
|
-
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)
|
|
917
|
+
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
884
918
|
`)
|
|
885
|
-
.run(roomId, agentId, nextOrdinal, timestamp, timestamp, timestamp, normalizedMetadata.host_id, normalizedMetadata.pid, normalizedMetadata.process_started_at, normalizedMetadata.session_kind, normalizedMetadata.display_name);
|
|
919
|
+
.run(roomId, agentId, nextOrdinal, timestamp, timestamp, timestamp, normalizedMetadata.host_id, normalizedMetadata.pid, normalizedMetadata.process_started_at, normalizedMetadata.session_kind, normalizedMetadata.display_name, normalizedMetadata.harness_name, normalizedMetadata.harness_session_id, normalizedMetadata.harness_host_id, normalizedMetadata.harness_pid, normalizedMetadata.harness_process_started_at);
|
|
886
920
|
}
|
|
887
921
|
mergeMemberProcessMetadata(room, existing, incoming) {
|
|
888
922
|
if (!this.shouldPreserveExactMemberProcessMetadata(room, existing, incoming)) {
|
|
@@ -893,9 +927,76 @@ export class TalkingStickService {
|
|
|
893
927
|
pid: existing.pid,
|
|
894
928
|
process_started_at: existing.process_started_at,
|
|
895
929
|
session_kind: existing.session_kind,
|
|
896
|
-
display_name: existing.display_name
|
|
930
|
+
display_name: existing.display_name,
|
|
931
|
+
harness_name: existing.harness_name,
|
|
932
|
+
harness_session_id: existing.harness_session_id,
|
|
933
|
+
harness_host_id: existing.harness_host_id,
|
|
934
|
+
harness_pid: existing.harness_pid,
|
|
935
|
+
harness_process_started_at: existing.harness_process_started_at
|
|
897
936
|
};
|
|
898
937
|
}
|
|
938
|
+
retireSupersededHarnessSessions(roomId, incomingAgentId, incomingMetadata, timestamp) {
|
|
939
|
+
if (!hasHarnessInstanceIdentity(incomingMetadata)) {
|
|
940
|
+
return [];
|
|
941
|
+
}
|
|
942
|
+
const room = this.requireRoom(roomId);
|
|
943
|
+
const targetAgentIds = [...new Set([room.owner, room.reserved_for])].filter((agentId) => agentId !== null && agentId !== incomingAgentId);
|
|
944
|
+
const supersededAgentIds = [];
|
|
945
|
+
for (const targetAgentId of targetAgentIds) {
|
|
946
|
+
const target = this.getMember(roomId, targetAgentId);
|
|
947
|
+
if (!target ||
|
|
948
|
+
!isSupersededHarnessInstance(target, incomingMetadata)) {
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
supersededAgentIds.push(targetAgentId);
|
|
952
|
+
this.db
|
|
953
|
+
.prepare("DELETE FROM room_members WHERE room_id = ? AND agent_id = ?")
|
|
954
|
+
.run(roomId, targetAgentId);
|
|
955
|
+
this.appendEvent({
|
|
956
|
+
room_id: roomId,
|
|
957
|
+
turn_id: room.turn_id,
|
|
958
|
+
event_type: "session_superseded",
|
|
959
|
+
from_agent_id: incomingAgentId,
|
|
960
|
+
to_agent_id: targetAgentId,
|
|
961
|
+
handoff: null,
|
|
962
|
+
reason: `superseded by newer ${incomingMetadata.harness_name} session from the same harness process`,
|
|
963
|
+
created_at: timestamp
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
if (supersededAgentIds.length === 0) {
|
|
967
|
+
return [];
|
|
968
|
+
}
|
|
969
|
+
const ownerSuperseded = room.owner
|
|
970
|
+
? supersededAgentIds.includes(room.owner)
|
|
971
|
+
: false;
|
|
972
|
+
const recipientSuperseded = room.reserved_for
|
|
973
|
+
? supersededAgentIds.includes(room.reserved_for)
|
|
974
|
+
: false;
|
|
975
|
+
const nextOwner = ownerSuperseded ? null : room.owner;
|
|
976
|
+
const nextReservedFor = recipientSuperseded ? null : room.reserved_for;
|
|
977
|
+
const nextState = room.state === "closed"
|
|
978
|
+
? "closed"
|
|
979
|
+
: nextOwner
|
|
980
|
+
? "owned"
|
|
981
|
+
: nextReservedFor
|
|
982
|
+
? "reserved"
|
|
983
|
+
: "idle";
|
|
984
|
+
this.db
|
|
985
|
+
.prepare(`
|
|
986
|
+
UPDATE path_rooms
|
|
987
|
+
SET owner = ?,
|
|
988
|
+
reserved_for = ?,
|
|
989
|
+
pending_handoff_event_seq = ?,
|
|
990
|
+
lease_id = ?,
|
|
991
|
+
lease_expires_at = ?,
|
|
992
|
+
claim_expires_at = ?,
|
|
993
|
+
state = ?,
|
|
994
|
+
updated_at = ?
|
|
995
|
+
WHERE room_id = ?
|
|
996
|
+
`)
|
|
997
|
+
.run(nextOwner, nextReservedFor, ownerSuperseded ? null : room.pending_handoff_event_seq, ownerSuperseded ? null : room.lease_id, ownerSuperseded ? null : room.lease_expires_at, recipientSuperseded ? null : room.claim_expires_at, nextState, timestamp, roomId);
|
|
998
|
+
return supersededAgentIds;
|
|
999
|
+
}
|
|
899
1000
|
shouldPreserveExactMemberProcessMetadata(room, existing, incoming) {
|
|
900
1001
|
const isCurrentHolderOrRecipient = room.owner === existing.agent_id || room.reserved_for === existing.agent_id;
|
|
901
1002
|
if (!isCurrentHolderOrRecipient) {
|
|
@@ -925,6 +1026,15 @@ export class TalkingStickService {
|
|
|
925
1026
|
throw new ProtocolError("unknown_member", "Agent must join the room before using this tool.", { to_agent_id: agentId });
|
|
926
1027
|
}
|
|
927
1028
|
}
|
|
1029
|
+
recordParkHint(roomId, agentId, pendingHandoffEventSeq) {
|
|
1030
|
+
this.db
|
|
1031
|
+
.prepare(`
|
|
1032
|
+
UPDATE room_members
|
|
1033
|
+
SET last_park_hint_event_seq = ?
|
|
1034
|
+
WHERE room_id = ? AND agent_id = ?
|
|
1035
|
+
`)
|
|
1036
|
+
.run(pendingHandoffEventSeq, roomId, agentId);
|
|
1037
|
+
}
|
|
928
1038
|
touchWaitingMember(roomId, agentId, timestamp) {
|
|
929
1039
|
const result = this.db
|
|
930
1040
|
.prepare(`
|
|
@@ -1439,7 +1549,12 @@ export class TalkingStickService {
|
|
|
1439
1549
|
pid: member.pid,
|
|
1440
1550
|
process_started_at: member.process_started_at,
|
|
1441
1551
|
session_kind: member.session_kind,
|
|
1442
|
-
display_name: member.display_name
|
|
1552
|
+
display_name: member.display_name,
|
|
1553
|
+
harness_name: member.harness_name,
|
|
1554
|
+
harness_session_id: member.harness_session_id,
|
|
1555
|
+
harness_host_id: member.harness_host_id,
|
|
1556
|
+
harness_pid: member.harness_pid,
|
|
1557
|
+
harness_process_started_at: member.harness_process_started_at
|
|
1443
1558
|
});
|
|
1444
1559
|
}
|
|
1445
1560
|
goneGraceMs() {
|
|
@@ -1542,7 +1657,12 @@ function normalizeProcessMetadata(processMetadata) {
|
|
|
1542
1657
|
pid: processMetadata?.pid ?? null,
|
|
1543
1658
|
process_started_at: processMetadata?.process_started_at ?? null,
|
|
1544
1659
|
session_kind: processMetadata?.session_kind ?? "mcp_harness",
|
|
1545
|
-
display_name: processMetadata?.display_name ?? null
|
|
1660
|
+
display_name: processMetadata?.display_name ?? null,
|
|
1661
|
+
harness_name: processMetadata?.harness_name ?? null,
|
|
1662
|
+
harness_session_id: processMetadata?.harness_session_id ?? null,
|
|
1663
|
+
harness_host_id: processMetadata?.harness_host_id ?? null,
|
|
1664
|
+
harness_pid: processMetadata?.harness_pid ?? null,
|
|
1665
|
+
harness_process_started_at: processMetadata?.harness_process_started_at ?? null
|
|
1546
1666
|
};
|
|
1547
1667
|
}
|
|
1548
1668
|
export function createDefaultProcessLivenessChecker(currentHostId, injectedInspector) {
|
|
@@ -1552,7 +1672,9 @@ export function createDefaultProcessLivenessChecker(currentHostId, injectedInspe
|
|
|
1552
1672
|
const inspector = injectedInspector ?? createSystemProcessInspector({ cacheTtlMs: 1_000 });
|
|
1553
1673
|
return (metadata) => {
|
|
1554
1674
|
if (metadata.pid === null ||
|
|
1675
|
+
metadata.pid === undefined ||
|
|
1555
1676
|
metadata.process_started_at === null ||
|
|
1677
|
+
metadata.process_started_at === undefined ||
|
|
1556
1678
|
metadata.process_started_at.trim() === "") {
|
|
1557
1679
|
return "unknown";
|
|
1558
1680
|
}
|
|
@@ -1586,6 +1708,34 @@ function hasExactProcessIdentity(metadata) {
|
|
|
1586
1708
|
metadata.process_started_at !== undefined &&
|
|
1587
1709
|
metadata.process_started_at.trim() !== "");
|
|
1588
1710
|
}
|
|
1711
|
+
function hasHarnessInstanceIdentity(metadata) {
|
|
1712
|
+
return (metadata.harness_name !== null &&
|
|
1713
|
+
metadata.harness_name !== undefined &&
|
|
1714
|
+
metadata.harness_name.trim() !== "" &&
|
|
1715
|
+
metadata.harness_session_id !== null &&
|
|
1716
|
+
metadata.harness_session_id !== undefined &&
|
|
1717
|
+
metadata.harness_session_id.trim() !== "" &&
|
|
1718
|
+
metadata.harness_pid !== null &&
|
|
1719
|
+
metadata.harness_pid !== undefined &&
|
|
1720
|
+
metadata.harness_process_started_at !== null &&
|
|
1721
|
+
metadata.harness_process_started_at !== undefined &&
|
|
1722
|
+
metadata.harness_process_started_at.trim() !== "");
|
|
1723
|
+
}
|
|
1724
|
+
function isSupersededHarnessInstance(existing, incoming) {
|
|
1725
|
+
if (!hasHarnessInstanceIdentity(existing) || !hasHarnessInstanceIdentity(incoming)) {
|
|
1726
|
+
return false;
|
|
1727
|
+
}
|
|
1728
|
+
return (existing.harness_name === incoming.harness_name &&
|
|
1729
|
+
existing.harness_host_id === incoming.harness_host_id &&
|
|
1730
|
+
existing.harness_pid === incoming.harness_pid &&
|
|
1731
|
+
existing.harness_process_started_at ===
|
|
1732
|
+
incoming.harness_process_started_at &&
|
|
1733
|
+
existing.harness_session_id !== incoming.harness_session_id);
|
|
1734
|
+
}
|
|
1735
|
+
function joinWarnings(...warnings) {
|
|
1736
|
+
const present = warnings.filter((warning) => Boolean(warning?.trim()));
|
|
1737
|
+
return present.length > 0 ? present.join(" ") : undefined;
|
|
1738
|
+
}
|
|
1589
1739
|
function sessionKindPriority(sessionKind) {
|
|
1590
1740
|
switch (sessionKind) {
|
|
1591
1741
|
case "human_guardian":
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Zombie Session Eviction (`/clear` Stale Stick Fix)
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
When a harness like Codex or Claude Code runs `/clear`, the in-process
|
|
6
|
+
session resets but the host harness process keeps running. The room sees:
|
|
7
|
+
|
|
8
|
+
- old `agent_id` (derived from the prior `CODEX_THREAD_ID` /
|
|
9
|
+
`CLAUDE_CODE_SESSION_ID`) still listed as owner or recipient
|
|
10
|
+
- liveness check via the stored `(pid, process_started_at)` still reports
|
|
11
|
+
"alive" — because the harness process is in fact alive
|
|
12
|
+
- new session joins with a different `agent_id`, sees the room owned, and
|
|
13
|
+
has no way to make progress
|
|
14
|
+
|
|
15
|
+
The old session can never reply (its thread is gone), so the room is
|
|
16
|
+
permanently stuck until an operator forces a takeover.
|
|
17
|
+
|
|
18
|
+
## Predicate
|
|
19
|
+
|
|
20
|
+
A member M in a room is **superseded** when, on `tt join` by a new member
|
|
21
|
+
M', all of the following hold:
|
|
22
|
+
|
|
23
|
+
- `M.harness_name == M'.harness_name`
|
|
24
|
+
- `M.harness_host_id == M'.harness_host_id`
|
|
25
|
+
- `M.harness_pid == M'.harness_pid`
|
|
26
|
+
- `M.harness_process_started_at == M'.harness_process_started_at`
|
|
27
|
+
- `M.harness_session_id != M'.harness_session_id`
|
|
28
|
+
- both members have non-null harness-instance metadata (legacy rows with
|
|
29
|
+
NULL fields are skipped — safe migration)
|
|
30
|
+
- M is currently the room owner or reserved recipient
|
|
31
|
+
|
|
32
|
+
Non-owner, non-reserved members are not evaluated. They are harmless
|
|
33
|
+
(they cannot block coordination) and age out through the existing
|
|
34
|
+
presence TTL.
|
|
35
|
+
|
|
36
|
+
## Identity Capture
|
|
37
|
+
|
|
38
|
+
`(harness_pid, harness_process_started_at)` is the **harness root**
|
|
39
|
+
process identity — the user-launched Codex/Claude process, not any
|
|
40
|
+
guardian subprocess or the current MCP child. Codex and Claude both keep
|
|
41
|
+
this process alive across `/clear`, so it is the durable fingerprint.
|
|
42
|
+
|
|
43
|
+
`identity.ts` walks the process ancestry from the `tt` invocation back to
|
|
44
|
+
the deepest ancestor whose command matches a known harness name and
|
|
45
|
+
records that process's PID + start time as the harness root. The current
|
|
46
|
+
in-process session id (`CODEX_THREAD_ID`, `CLAUDE_CODE_SESSION_ID`, or
|
|
47
|
+
the ancestry-derived fallback) is recorded as `harness_session_id`.
|
|
48
|
+
|
|
49
|
+
These fields are written to `room_members` on join. They are independent
|
|
50
|
+
from the existing `pid` / `process_started_at` columns, which continue
|
|
51
|
+
to track the *currently active* process (initially the harness, later
|
|
52
|
+
the guardian once `tt wait` succeeds).
|
|
53
|
+
|
|
54
|
+
## Schema
|
|
55
|
+
|
|
56
|
+
Migration 6 (`src/db.ts`) adds five nullable columns to `room_members`:
|
|
57
|
+
|
|
58
|
+
- `harness_name TEXT`
|
|
59
|
+
- `harness_session_id TEXT`
|
|
60
|
+
- `harness_host_id TEXT`
|
|
61
|
+
- `harness_pid INTEGER`
|
|
62
|
+
- `harness_process_started_at TEXT`
|
|
63
|
+
|
|
64
|
+
All columns are nullable so the migration is safe for existing rows.
|
|
65
|
+
Rows with NULL fields are excluded from the supersession predicate.
|
|
66
|
+
|
|
67
|
+
## Guardian Propagation
|
|
68
|
+
|
|
69
|
+
`tt wait` and `tt take` spawn a `tt guard` subprocess that holds the
|
|
70
|
+
lease and rejoins the room with `session_kind: human_guardian`. The
|
|
71
|
+
guardian must carry forward the original harness-instance fields, not
|
|
72
|
+
its own (it is not a harness). This is done by passing
|
|
73
|
+
`--harness-name`, `--harness-session-id`, `--harness-host-id`,
|
|
74
|
+
`--harness-pid`, and `--harness-process-started-at` to `tt guard` on
|
|
75
|
+
spawn. The guardian merges them into its derived identity before
|
|
76
|
+
rejoining.
|
|
77
|
+
|
|
78
|
+
The result: the member row's `pid` / `process_started_at` columns get
|
|
79
|
+
overwritten with the guardian's process (correct for liveness), but the
|
|
80
|
+
`harness_*` columns stay pinned to the original harness root.
|
|
81
|
+
|
|
82
|
+
## Eviction Action
|
|
83
|
+
|
|
84
|
+
On a qualifying join, in one transaction:
|
|
85
|
+
|
|
86
|
+
1. `DELETE FROM room_members WHERE room_id = ? AND agent_id = ?` for each
|
|
87
|
+
superseded member.
|
|
88
|
+
2. Append a `session_superseded` event with
|
|
89
|
+
`from_agent_id = incoming`, `to_agent_id = superseded`, and a reason
|
|
90
|
+
string identifying the harness.
|
|
91
|
+
3. If the superseded member was the room owner, clear
|
|
92
|
+
`owner`, `lease_id`, `lease_expires_at`, and
|
|
93
|
+
`pending_handoff_event_seq`.
|
|
94
|
+
4. If the superseded member was the reserved recipient, clear
|
|
95
|
+
`reserved_for` and `claim_expires_at`. **Preserve**
|
|
96
|
+
`pending_handoff_event_seq` so the next claimant still receives the
|
|
97
|
+
original handoff.
|
|
98
|
+
5. Recompute `state` to `owned`, `reserved`, `idle`, or `closed` as
|
|
99
|
+
appropriate.
|
|
100
|
+
|
|
101
|
+
The `joinPath` response includes a `warning` listing the superseded
|
|
102
|
+
agent ids.
|
|
103
|
+
|
|
104
|
+
## Event Type Choice: `session_superseded`
|
|
105
|
+
|
|
106
|
+
A new event type rather than reusing `kick`. Rationale:
|
|
107
|
+
|
|
108
|
+
- `kick` is operator-coded — humans reading `tt events` interpret a kick
|
|
109
|
+
as an explicit intervention.
|
|
110
|
+
- A supersession is automatic, not operator-driven, and never blocks the
|
|
111
|
+
new session.
|
|
112
|
+
- A dedicated type lets skill text instruct harnesses to treat it as
|
|
113
|
+
informational, distinct from `takeover_available`.
|
|
114
|
+
|
|
115
|
+
The CLI's default event formatter renders `session_superseded` the same
|
|
116
|
+
way as any other event (timestamp, type, from→to, reason) — no special
|
|
117
|
+
display logic needed.
|
|
118
|
+
|
|
119
|
+
## What Stays Out
|
|
120
|
+
|
|
121
|
+
- No notification to the old session before eviction. By construction
|
|
122
|
+
the old session cannot read or reply.
|
|
123
|
+
- No silent retention of a deactivated member row. The append-only event
|
|
124
|
+
log is the audit record; member rows are operational state.
|
|
125
|
+
- No change to `tt take` / `takeover_available` semantics. The
|
|
126
|
+
supersession path runs strictly on `tt join`, before any takeover
|
|
127
|
+
decision.
|
|
128
|
+
- No change to plain human sessions. A `human_guardian` row is only
|
|
129
|
+
subject to supersession when it carries harness-instance metadata from
|
|
130
|
+
a harness-launched guardian; standalone human CLI rows have NULL
|
|
131
|
+
harness fields and are skipped.
|
|
132
|
+
|
|
133
|
+
## Tests
|
|
134
|
+
|
|
135
|
+
`tests/talking-stick.test.ts` covers:
|
|
136
|
+
|
|
137
|
+
1. **Owner supersession.** Same-process new session joins, prior owner
|
|
138
|
+
is deleted, `session_superseded` event recorded, room returns to
|
|
139
|
+
`idle`, new session's `tt wait` immediately grants the turn with
|
|
140
|
+
`reason: open_claim`.
|
|
141
|
+
2. **Different-process non-supersession.** A different Codex process
|
|
142
|
+
joins; the existing owner is untouched; the new joiner gets
|
|
143
|
+
`not_yet` and sees the original owner.
|
|
144
|
+
3. **Recipient supersession preserves handoff.** Prior recipient is
|
|
145
|
+
evicted, but the handoff event remains pending; the new session's
|
|
146
|
+
`tt wait` returns `reason: sequence` with the original handoff
|
|
147
|
+
intact.
|
|
148
|
+
|
|
149
|
+
## Open Questions
|
|
150
|
+
|
|
151
|
+
None blocking. Possible follow-ups:
|
|
152
|
+
|
|
153
|
+
- Surface superseded events in `tt state --json` as a recent-history
|
|
154
|
+
field for operators inspecting why ownership changed.
|
|
155
|
+
- Consider supersession during `tt wait` as a defensive sweep (currently
|
|
156
|
+
join-only; depends on whether real-world failure modes show join
|
|
157
|
+
being skipped).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Talking Stick 0.4.5
|
|
2
|
+
|
|
3
|
+
Date: 2026-05-12
|
|
4
|
+
|
|
5
|
+
## Added
|
|
6
|
+
- **Harness-instance member metadata.** New nullable columns on `room_members` (`harness_name`, `harness_session_id`, `harness_host_id`, `harness_pid`, `harness_process_started_at`) track the root harness process and the in-process session id independently of the row's current liveness fields. Identity resolution walks the process ancestry to populate them; `tt guard` carries them forward on spawn so guardian rejoins do not clobber them.
|
|
7
|
+
- **`session_superseded` event type.** Emitted when `tt join` detects that the room's owner or reserved recipient comes from the same harness process but a different in-process session (the `/clear` case). The superseded member row is deleted, owner/reservation/lease state is cleared as appropriate, and pending recipient handoffs are preserved for the next claimant. Legacy member rows with NULL harness-instance fields are skipped, so the migration is safe.
|
|
8
|
+
|
|
9
|
+
## Fixed
|
|
10
|
+
- **`/clear` no longer leaves a permanent stale stick holder.** When a harness like Codex or Claude Code resets its in-process session (e.g. `/clear`) while still holding or being reserved for the stick, the next `tt join` from the new in-process session evicts the prior agent and clears the lease so the new session can proceed. Previously the room stayed stuck until an operator forced a takeover.
|
|
11
|
+
- **Park mode no longer looks like active pending work.** `tt wait --park` now returns immediately when the room is idle and unreserved, with a JSON hint telling agents to use normal `tt wait --json` when a handoff or operator instruction leaves review/release work pending. The bundled instructions now reserve park mode for true passive standby.
|
|
12
|
+
|
|
13
|
+
## Verification
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm run typecheck
|
|
17
|
+
npm test
|
|
18
|
+
npm run build
|
|
19
|
+
node dist/cli.js --help
|
|
20
|
+
git diff --check
|
|
21
|
+
npm pack --dry-run
|
|
22
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Talking Stick 0.4.6
|
|
2
|
+
|
|
3
|
+
Date: 2026-05-12
|
|
4
|
+
|
|
5
|
+
## Added
|
|
6
|
+
- **`room_members.last_park_hint_event_seq`.** New nullable INTEGER column (migration 7) tracking which pending-handoff event sequence a member has already been hinted about via park mode. Used to give the `auto_claim_disabled` hint at most once per (member, pending handoff) pair.
|
|
7
|
+
|
|
8
|
+
## Fixed
|
|
9
|
+
- **Park no longer spins on truly idle rooms.** `tt wait --park` short-returns with `reason: auto_claim_disabled` and a hint only the first time a member parks against a given pending handoff in an idle room. Subsequent parks by the same member against the same pending handoff long-poll quietly. Truly idle (no pending handoff) always long-polls. A fresh pending handoff (newer event sequence) hints again, and each member is hinted independently. Previously the short-return fired on every park call, which kept a naive re-park loop spinning even after the agent saw the hint.
|
|
10
|
+
|
|
11
|
+
## Verification
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm run typecheck
|
|
15
|
+
npm test
|
|
16
|
+
npm run build
|
|
17
|
+
node dist/cli.js --help
|
|
18
|
+
git diff --check
|
|
19
|
+
npm pack --dry-run
|
|
20
|
+
```
|
package/package.json
CHANGED
|
@@ -63,6 +63,8 @@ Keep the returned room id and canonical path in mind. The current working direct
|
|
|
63
63
|
|
|
64
64
|
On freshly invoked multi-agent tasks, give peers a short window to join before deciding you are alone. Use a normal wait timeout or spend about a minute on read-only repo orientation while other harnesses appear.
|
|
65
65
|
|
|
66
|
+
If `tt join` returns a `warning` containing `Superseded previous harness session(s): ...`, that is the normal path after a harness `/clear` (or equivalent in-process session reset). The prior session in this same harness process held or was reserved for the stick, can no longer reply, and has been removed from the room. A `session_superseded` event records who replaced whom. This is informational, not a takeover decision — proceed normally.
|
|
67
|
+
|
|
66
68
|
After joining, load editable collaboration instructions once:
|
|
67
69
|
|
|
68
70
|
```sh
|
|
@@ -95,6 +97,8 @@ tt wait --json
|
|
|
95
97
|
|
|
96
98
|
The default wait timeout is `110s`, which is the normal active-coordination setting. If your harness has a shorter tool timeout, override with the longest safe value and immediately wait again when it returns without granting the turn. Do not busy-loop with short waits.
|
|
97
99
|
|
|
100
|
+
If a handoff, message, or operator instruction leaves review, release, or other task work pending, use normal `tt wait --json`; do not use park mode. `tt wait --park --json` is only for passive standby when no task is pending and you are waiting for operator input or another external signal.
|
|
101
|
+
|
|
98
102
|
Possible outcomes:
|
|
99
103
|
|
|
100
104
|
- `your_turn`: you may proceed
|
|
@@ -110,7 +114,7 @@ Prefer to run `tt wait` in the background if your harness supports background co
|
|
|
110
114
|
|
|
111
115
|
Prefer wait cycles over scheduled wakeups. A direct long-poll stays aligned with other agents and usually notices a released stick within the same cycle. Use scheduled wakeups only when your harness cannot keep a wait running in the background.
|
|
112
116
|
|
|
113
|
-
Do not replace `tt wait` with an event receiver. `tt events --wait` is only a wake channel for messages and handoff/reservation events. If it exits with a pass, release, assignment, or message, process the event, then run or continue `tt wait --json
|
|
117
|
+
Do not replace `tt wait` with an event receiver. `tt events --wait` is only a wake channel for messages and handoff/reservation events. If it exits with a pass, release, assignment, or message, process the event, then run or continue normal `tt wait --json` whenever work is pending; do not touch shared files unless that wait returns `your_turn`.
|
|
114
118
|
|
|
115
119
|
If you do not have the stick:
|
|
116
120
|
|
|
@@ -177,6 +181,8 @@ If `tt wait` reports `takeover_available`:
|
|
|
177
181
|
- if takeover is chosen, run `tt take --reason "..." --json`
|
|
178
182
|
- after takeover, run `tt events --target any --json` so you can reconstruct the last handoff before touching code
|
|
179
183
|
|
|
184
|
+
`session_superseded` is **not** a takeover reason — it is a separate informational event emitted on `tt join` when a new in-process harness session replaces a prior one (see §2). It never requires a takeover decision.
|
|
185
|
+
|
|
180
186
|
If the operator explicitly tells you to take over despite a reservation or live owner, use:
|
|
181
187
|
|
|
182
188
|
```sh
|
|
@@ -210,6 +216,7 @@ Use `tt assign <agent_id> . --stdin` only when a specific named member must go n
|
|
|
210
216
|
- they have unique context the next step requires
|
|
211
217
|
- they hold a credential or capability others lack
|
|
212
218
|
- the operator explicitly addressed the work to them
|
|
219
|
+
- the handoff asks that named peer for a concrete review or release action
|
|
213
220
|
|
|
214
221
|
Otherwise release. Pinning turns between two agents is an antipattern because it can lock humans out of their own room.
|
|
215
222
|
|