dsh-tower 2.0.3 → 2.1.0-beta.1
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/CHANGELOG.md +141 -0
- package/COMPATIBILITY.md +38 -0
- package/CONTRIBUTING.md +150 -2
- package/README.en.md +59 -16
- package/README.md +57 -16
- package/SECURITY.md +31 -1
- package/adapters/default.js +10 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/batch.js +79 -0
- package/app/board.js +25 -0
- package/app/checkCompletion.js +76 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +108 -0
- package/app/init.js +249 -0
- package/app/merge.js +567 -0
- package/app/messages.js +66 -0
- package/app/mission.js +35 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +42 -0
- package/app/provision.js +193 -0
- package/app/read.js +57 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +145 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +184 -0
- package/app/tower.js +524 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +33 -1
- package/docs/architecture/overview.md +114 -14
- package/docs/board.md +72 -0
- package/docs/examples/first-mission.md +2 -1
- package/docs/faq.md +11 -7
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +22 -6
- package/docs/guide/quickstart.md +12 -7
- package/docs/guide/release.md +125 -0
- package/docs/guide/scenarios-and-philosophy.md +86 -69
- package/docs/operations/security.md +65 -14
- package/docs/reference/blockers.md +11 -10
- package/docs/reference/config.generated.md +66 -0
- package/docs/reference/config.md +11 -7
- package/docs/reference/errors.generated.md +39 -0
- package/docs/reference/errors.md +10 -2
- package/docs/reference/events.md +7 -4
- package/docs/reference/testing.md +2 -1
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +8 -7
- package/docs/zh/README.md +4 -3
- package/docs/zh/faq.md +9 -6
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +17 -5
- package/docs/zh/guide/quickstart.md +13 -8
- package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
- package/docs/zh/operations/security.md +43 -7
- package/docs/zh/reference/blockers.md +10 -10
- package/docs/zh/reference/config.md +11 -7
- package/docs/zh/reference/errors.md +4 -1
- package/docs/zh/reference/events.md +6 -3
- package/docs/zh/reference/testing.md +3 -2
- package/docs/zh/reference/tools.md +8 -7
- package/domain/collab/batch.js +112 -0
- package/domain/collab/messages.js +127 -0
- package/domain/collab/roster.js +158 -0
- package/domain/collab/spawn.js +188 -0
- package/domain/config/schema.js +404 -0
- package/domain/contracts/outputs.js +279 -0
- package/domain/contracts/tool-inputs/collaboration.js +58 -0
- package/domain/contracts/tool-inputs/findings.js +29 -0
- package/domain/contracts/tool-inputs/index.js +94 -0
- package/domain/contracts/tool-inputs/integration.js +37 -0
- package/domain/contracts/tool-inputs/missions.js +94 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +96 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +176 -0
- package/domain/integration/init.js +225 -0
- package/domain/integration/liveness.js +31 -0
- package/domain/integration/merge.js +377 -0
- package/domain/integration/provision.js +124 -0
- package/domain/integration/rebase.js +236 -0
- package/domain/integration/review.js +52 -0
- package/domain/integration/teardown.js +118 -0
- package/domain/mission.js +68 -0
- package/domain/missions/completion.js +114 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +185 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +118 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +128 -0
- package/domain/report.js +59 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +105 -0
- package/domain/state/schema.js +233 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +33 -0
- package/domain/summary.js +114 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +86 -34
- package/lib/briefing.js +176 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +54 -302
- package/lib/doctor.js +108 -9
- package/lib/git.js +93 -15
- package/lib/hmr.js +4 -3
- package/lib/leases.js +3 -2
- package/lib/legacy.js +20 -9
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +35 -33
- package/lib/persistence.js +349 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/store.js +57 -85
- package/lib/tests.js +46 -11
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +46 -22
- package/lib/worktreeOwnership.js +5 -4
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +33 -4
- package/ports/clock.js +6 -0
- package/ports/ids.js +7 -0
- package/ports/index.js +2 -0
- package/ports/store.js +11 -0
- package/ports/vcs.js +11 -0
- package/scripts/architecture.mjs +94 -0
- package/scripts/check-docs.mjs +44 -3
- package/scripts/check-pack.mjs +49 -0
- package/scripts/domain-metrics.mjs +48 -0
- package/scripts/generate-config-reference.mjs +46 -0
- package/scripts/generate-error-reference.mjs +29 -0
- package/scripts/generate-tools-reference.mjs +56 -0
- package/scripts/issue-audit.mjs +259 -0
- package/scripts/lib/cli.mjs +76 -0
- package/scripts/lib/tmp.mjs +20 -0
- package/scripts/mount-smoke/fixtures.mjs +162 -0
- package/scripts/mount-smoke/host.json +4 -0
- package/scripts/mount-smoke/probe.mjs +125 -0
- package/scripts/mount-smoke/surface.mjs +141 -0
- package/scripts/mount-smoke.mjs +270 -0
- package/scripts/pack-manifest.txt +203 -0
- package/scripts/release-check.mjs +303 -0
- package/skills/tower-recovery.md +7 -5
- package/tools/adopt.js +72 -0
- package/tools/batch.js +78 -0
- package/tools/complete.js +53 -0
- package/tools/diff.js +39 -0
- package/tools/doctor.js +50 -0
- package/tools/finding.js +40 -0
- package/tools/findings.js +39 -0
- package/tools/import-legacy.js +42 -0
- package/tools/inbox.js +39 -0
- package/tools/index.js +39 -0
- package/tools/init.js +63 -0
- package/tools/interrupt.js +42 -0
- package/tools/legacy-map.js +44 -0
- package/tools/merge.js +39 -0
- package/tools/mission.js +41 -0
- package/tools/plan.js +39 -0
- package/tools/preflight.js +44 -0
- package/tools/rebase.js +39 -0
- package/tools/recover.js +45 -0
- package/tools/reload.js +52 -0
- package/tools/resource.js +54 -0
- package/tools/review.js +45 -0
- package/tools/roster.js +60 -0
- package/tools/send.js +147 -0
- package/tools/spawn.js +196 -0
- package/tools/status.js +94 -0
- package/tools/teardown.js +42 -0
- package/tools/test.js +61 -0
- package/types/cordis.d.ts +6 -0
- package/types/index.d.ts +339 -0
- package/ui-bundle/README.md +111 -0
- package/ui-bundle/client.js +1980 -0
- package/ui-bundle/cordis.patch.yml +3 -0
- package/ui-bundle/icon.svg +6 -0
- package/ui-bundle/index.js +2 -0
- package/ui-bundle/locale/en.json +6 -0
- package/ui-bundle/locale/zh.json +6 -0
- package/ui-bundle/package.json +35 -0
- package/lib/errors.js +0 -111
- package/lib/tools.js +0 -2250
- package/lib/tower.js +0 -2689
- /package/{lib → domain}/batch.js +0 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// tower_resource: advisory resource leases.
|
|
2
|
+
import { Tower } from '../app/tower.js'
|
|
3
|
+
import { SCHEMAS, output } from '../domain/contracts/outputs.js'
|
|
4
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
5
|
+
import { acquireLease, releaseLease, inspectLeases } from '../lib/leases.js'
|
|
6
|
+
import { ResourceInput } from '../domain/contracts/tool-inputs/index.js'
|
|
7
|
+
import { LEASE_ACTION } from '../domain/vocabulary.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {any} ctx
|
|
11
|
+
* @param {{ define: import('../types/index.js').DefineTool, actorFor: any, requireTowerActor: any }} deps
|
|
12
|
+
*/
|
|
13
|
+
export function registerResourceTool(ctx, deps) {
|
|
14
|
+
const { define, actorFor, requireTowerActor } = deps
|
|
15
|
+
define(ctx, {
|
|
16
|
+
name: 'tower_resource',
|
|
17
|
+
description:
|
|
18
|
+
'Advisory resource leases per repository (coordination only; no OS CPU/memory hard cap). acquire/release require a token and are tower-only; status is read-only. Expired leases are never taken over silently.',
|
|
19
|
+
parameters: ResourceInput,
|
|
20
|
+
output: output(SCHEMAS.resource),
|
|
21
|
+
async execute(args, exec) {
|
|
22
|
+
const tower = await Tower.open(args.repoRoot)
|
|
23
|
+
const actor = await actorFor(tower, exec)
|
|
24
|
+
const redact = (leases) => leases.map(({ token, ...lease }) => lease)
|
|
25
|
+
if (args.action === LEASE_ACTION.status) {
|
|
26
|
+
const info = await inspectLeases(tower.commonDir)
|
|
27
|
+
const leases = redact(info.leases ?? [])
|
|
28
|
+
return { ok: true, action: LEASE_ACTION.status, ...info, leases }
|
|
29
|
+
}
|
|
30
|
+
requireTowerActor(actor, args.action === LEASE_ACTION.acquire ? 'acquire a lease' : 'release a lease')
|
|
31
|
+
if (typeof args.name !== 'string' || args.name.trim().length === 0) {
|
|
32
|
+
throw new TowerToolError('invalid-argument', 'tower_resource requires name')
|
|
33
|
+
}
|
|
34
|
+
const name = args.name.trim()
|
|
35
|
+
const ttlMs = Number(args.ttlMs ?? tower.config.leases?.ttlMs ?? 900000)
|
|
36
|
+
if (args.action === LEASE_ACTION.acquire) {
|
|
37
|
+
const lease = await acquireLease(tower.commonDir, {
|
|
38
|
+
name,
|
|
39
|
+
holder: args.holder ?? actor.name,
|
|
40
|
+
token: args.token ?? crypto.randomUUID(),
|
|
41
|
+
pid: process.pid,
|
|
42
|
+
ttlMs,
|
|
43
|
+
})
|
|
44
|
+
return { ok: true, action: LEASE_ACTION.acquire, lease }
|
|
45
|
+
}
|
|
46
|
+
const token = args.token
|
|
47
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
48
|
+
throw new TowerToolError('invalid-argument', 'tower_resource release requires token')
|
|
49
|
+
}
|
|
50
|
+
const released = await releaseLease(tower.commonDir, { name, token, force: args.force === true })
|
|
51
|
+
return { ok: true, action: LEASE_ACTION.release, released, leases: redact((await inspectLeases(tower.commonDir)).leases) }
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
}
|
package/tools/review.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// tower_review: independent review verdict.
|
|
2
|
+
import { Tower } from '../app/tower.js'
|
|
3
|
+
import { SCHEMAS, output } from '../domain/contracts/outputs.js'
|
|
4
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
5
|
+
import { ReviewInput } from '../domain/contracts/tool-inputs/index.js'
|
|
6
|
+
import { isReviewerRole, isTowerRole } from '../domain/vocabulary.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {any} ctx
|
|
10
|
+
* @param {{ define: import('../types/index.js').DefineTool, actorFor: any }} deps
|
|
11
|
+
*/
|
|
12
|
+
export function registerReviewTool(ctx, deps) {
|
|
13
|
+
const { define, actorFor } = deps
|
|
14
|
+
define(ctx, {
|
|
15
|
+
name: 'tower_review',
|
|
16
|
+
description:
|
|
17
|
+
'Submit an independent review verdict for a completed mission, bound to the current branch tip. status is "clean", "p1-Nitems", or "p2-Nitems"; merge is "merge", "fix-then-merge", or "hold" (only clean + merge is later approval). Non-clean returns the mission to active. Reviewers are capped at 5 rounds per branch; a tower-submitted review is break-glass and requires breakGlassReason.',
|
|
18
|
+
parameters: ReviewInput,
|
|
19
|
+
aliases: {"missionId":["id"]},
|
|
20
|
+
requiresMissionId: true,
|
|
21
|
+
board: true,
|
|
22
|
+
output: output(SCHEMAS.review),
|
|
23
|
+
async execute(args, exec) {
|
|
24
|
+
const tower = await Tower.open(args.repoRoot)
|
|
25
|
+
const actor = await actorFor(tower, exec)
|
|
26
|
+
if (!isReviewerRole(actor.role) && !isTowerRole(actor.role)) {
|
|
27
|
+
throw new TowerToolError('not-authorized', `only an assigned reviewer or the tower may review; ${actor.name} is ${actor.role}`)
|
|
28
|
+
}
|
|
29
|
+
if (isTowerRole(actor.role) && (typeof args.breakGlassReason !== 'string' || args.breakGlassReason.trim().length === 0)) {
|
|
30
|
+
throw new TowerToolError('not-authorized', 'a tower-submitted review is break-glass and requires a non-empty breakGlassReason')
|
|
31
|
+
}
|
|
32
|
+
return tower.submitReview({
|
|
33
|
+
missionId: args.missionId,
|
|
34
|
+
agentId: String(exec.agent.id),
|
|
35
|
+
actor,
|
|
36
|
+
status: args.status,
|
|
37
|
+
merge: args.merge,
|
|
38
|
+
findings: args.findings,
|
|
39
|
+
checks: args.checks,
|
|
40
|
+
decision: args.decision,
|
|
41
|
+
breakGlassReason: args.breakGlassReason,
|
|
42
|
+
})
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
}
|
package/tools/roster.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// tower_roster: authoritative roster activity.
|
|
2
|
+
import { Tower } from '../app/tower.js'
|
|
3
|
+
import { SCHEMAS, output } from '../domain/contracts/outputs.js'
|
|
4
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
5
|
+
import { RosterInput } from '../domain/contracts/tool-inputs/index.js'
|
|
6
|
+
import { PARENT_STATUS_OK, ROSTER_ACTIVITY, ROSTER_GONE_STATES } from '../domain/vocabulary.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {any} ctx
|
|
10
|
+
* @param {{ define: import('../types/index.js').DefineTool }} deps
|
|
11
|
+
*/
|
|
12
|
+
export function registerRosterTool(ctx, deps) {
|
|
13
|
+
const { define } = deps
|
|
14
|
+
define(ctx, {
|
|
15
|
+
name: 'tower_roster',
|
|
16
|
+
description:
|
|
17
|
+
'List tower roster agents with authoritative live activity from the subagent catalog (running/inactive/missing/unknown). Read-only; never resumes children. Use before completion or merge decisions.',
|
|
18
|
+
parameters: RosterInput,
|
|
19
|
+
output: output(SCHEMAS.roster),
|
|
20
|
+
async execute(args) {
|
|
21
|
+
const tower = await Tower.open(args.repoRoot)
|
|
22
|
+
const view = await tower.rosterView()
|
|
23
|
+
const subagents = ctx.get('subagents')
|
|
24
|
+
if (!subagents) throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot observe roster')
|
|
25
|
+
|
|
26
|
+
const parents = [...new Set(view.roster.map((entry) => entry.parentSessionId).filter(Boolean))]
|
|
27
|
+
const activity = new Map()
|
|
28
|
+
const parentStatus = {}
|
|
29
|
+
for (const parent of parents) {
|
|
30
|
+
try {
|
|
31
|
+
const rows = await subagents.listDescendants(parent)
|
|
32
|
+
for (const row of rows) activity.set(String(row.id), row.activity ?? ROSTER_ACTIVITY.unknown)
|
|
33
|
+
parentStatus[parent] = PARENT_STATUS_OK
|
|
34
|
+
} catch (error) {
|
|
35
|
+
parentStatus[parent] = `unavailable: ${String(error?.message ?? error)}`
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const staleMs = Number(tower.config.spawn?.staleMs) || 0
|
|
40
|
+
const now = Date.now()
|
|
41
|
+
const roster = view.roster.map((entry) => {
|
|
42
|
+
const observed = activity.get(entry.agentId)
|
|
43
|
+
const known = parentStatus[entry.parentSessionId] === PARENT_STATUS_OK
|
|
44
|
+
const stale =
|
|
45
|
+
staleMs > 0 &&
|
|
46
|
+
observed === undefined &&
|
|
47
|
+
known &&
|
|
48
|
+
!ROSTER_GONE_STATES.includes(entry.state) &&
|
|
49
|
+
now - Number(entry.spawnedAt ?? 0) > staleMs
|
|
50
|
+
return {
|
|
51
|
+
...entry,
|
|
52
|
+
activity: observed ?? (known ? ROSTER_ACTIVITY.missing : ROSTER_ACTIVITY.unknown),
|
|
53
|
+
observed: observed !== undefined,
|
|
54
|
+
stale,
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
return { revision: view.revision, roster, parentStatus, missions: view.missions }
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
}
|
package/tools/send.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// tower_send: durable inbox message delivery.
|
|
2
|
+
import { Tower } from '../app/tower.js'
|
|
3
|
+
import { SCHEMAS, output } from '../domain/contracts/outputs.js'
|
|
4
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
5
|
+
import { SendInput } from '../domain/contracts/tool-inputs/index.js'
|
|
6
|
+
import {
|
|
7
|
+
ROLE,
|
|
8
|
+
ROSTER_ACTIVITY,
|
|
9
|
+
isBroadcast,
|
|
10
|
+
isRunningActivity,
|
|
11
|
+
isTowerRecipient,
|
|
12
|
+
isTowerRole,
|
|
13
|
+
isUnknownRole,
|
|
14
|
+
} from '../domain/vocabulary.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {any} ctx
|
|
18
|
+
* @param {{
|
|
19
|
+
* define: import('../types/index.js').DefineTool,
|
|
20
|
+
* actorFor: any,
|
|
21
|
+
* checkSendRate: any,
|
|
22
|
+
* }} deps
|
|
23
|
+
*/
|
|
24
|
+
export function registerSendTool(ctx, deps) {
|
|
25
|
+
const { define, actorFor, checkSendRate } = deps
|
|
26
|
+
define(ctx, {
|
|
27
|
+
name: 'tower_send',
|
|
28
|
+
description:
|
|
29
|
+
'Send a durable inbox message to a tower participant: a roster agent name, "tower", or "all" (broadcast). The message is persisted first; when the recipient is a currently running roster agent the tower also steers it into that child\'s current turn. Returns delivery results.',
|
|
30
|
+
parameters: SendInput,
|
|
31
|
+
aliases: { consentRef: ['consent_ref'] },
|
|
32
|
+
output: output(SCHEMAS.send),
|
|
33
|
+
async execute(args, exec) {
|
|
34
|
+
const tower = await Tower.open(args.repoRoot)
|
|
35
|
+
checkSendRate(tower.config, args.repoRoot)
|
|
36
|
+
const actor = await actorFor(tower, exec)
|
|
37
|
+
const view = await tower.rosterView()
|
|
38
|
+
const from = isTowerRole(actor.role) ? ROLE.tower : actor.name
|
|
39
|
+
if (isUnknownRole(actor.role)) {
|
|
40
|
+
throw new TowerToolError('not-authorized', 'tower_send requires a registered roster agent or the tower')
|
|
41
|
+
}
|
|
42
|
+
if (isBroadcast(args.to) && !isTowerRole(actor.role)) {
|
|
43
|
+
throw new TowerToolError('not-authorized', 'only the tower may broadcast to "all"')
|
|
44
|
+
}
|
|
45
|
+
const sendConfig = tower.config.send ?? {}
|
|
46
|
+
const interrupt =
|
|
47
|
+
args.interrupt === undefined ? sendConfig.interruptDefault === true : args.interrupt === true
|
|
48
|
+
if (interrupt && !isTowerRole(actor.role)) {
|
|
49
|
+
throw new TowerToolError('not-authorized', 'only the tower may interrupt a recipient with tower_send')
|
|
50
|
+
}
|
|
51
|
+
if (!isTowerRole(actor.role)) {
|
|
52
|
+
const self = view.roster.find((entry) => entry.agentId === actor.agentId)
|
|
53
|
+
const target = view.roster.find((entry) => entry.name === args.to)
|
|
54
|
+
const allowed =
|
|
55
|
+
isTowerRecipient(args.to) ||
|
|
56
|
+
(self && target && target.agentId !== self.agentId && target.missionId === self.missionId)
|
|
57
|
+
if (!allowed) {
|
|
58
|
+
throw new TowerToolError('not-authorized', `${actor.name} may only message the tower or agents on the same mission`)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (typeof args.subject !== 'string' || args.subject.trim().length === 0) {
|
|
62
|
+
throw new TowerToolError('invalid-argument', 'tower_send requires a subject')
|
|
63
|
+
}
|
|
64
|
+
if (typeof args.body !== 'string' || args.body.trim().length === 0) {
|
|
65
|
+
throw new TowerToolError('invalid-argument', 'tower_send requires a body')
|
|
66
|
+
}
|
|
67
|
+
const message = await tower.recordMessage({
|
|
68
|
+
from,
|
|
69
|
+
to: args.to,
|
|
70
|
+
subject: args.subject.trim(),
|
|
71
|
+
body: args.body,
|
|
72
|
+
scope: args.scope,
|
|
73
|
+
consentRef: args.consentRef,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const subagents = ctx.get('subagents')
|
|
77
|
+
const results = []
|
|
78
|
+
if (!subagents) {
|
|
79
|
+
results.push({ target: '*', pushed: false, reason: 'subagents service unavailable; persisted only' })
|
|
80
|
+
} else {
|
|
81
|
+
const roster = view.roster
|
|
82
|
+
const parents = [...new Set(roster.map((entry) => entry.parentSessionId).filter(Boolean))]
|
|
83
|
+
const activity = new Map()
|
|
84
|
+
for (const parent of parents) {
|
|
85
|
+
try {
|
|
86
|
+
const rows = await subagents.listDescendants(parent)
|
|
87
|
+
for (const row of rows) activity.set(String(row.id), row.activity ?? ROSTER_ACTIVITY.unknown)
|
|
88
|
+
} catch {
|
|
89
|
+
// an unreadable parent catalog only means "do not wake it"
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const targets =
|
|
93
|
+
isBroadcast(args.to) ? roster.map((entry) => entry.name) : [args.to]
|
|
94
|
+
for (const targetName of targets) {
|
|
95
|
+
if (isTowerRecipient(targetName)) {
|
|
96
|
+
results.push({ target: ROLE.tower, pushed: false, reason: 'tower reads its inbox' })
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
const entry = roster.find((candidate) => candidate.name === targetName)
|
|
100
|
+
if (!entry) {
|
|
101
|
+
results.push({ target: targetName, pushed: false, reason: 'not in roster' })
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
if (!isRunningActivity(activity.get(entry.agentId))) {
|
|
105
|
+
results.push({ target: targetName, pushed: false, reason: 'not running; queued in inbox' })
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
let interrupted = false
|
|
109
|
+
if (interrupt) {
|
|
110
|
+
try {
|
|
111
|
+
subagents.interrupt(entry.agentId, { kind: 'ancestor', agent: exec.agent })
|
|
112
|
+
interrupted = true
|
|
113
|
+
await tower.recordInterrupt({
|
|
114
|
+
agentId: entry.agentId,
|
|
115
|
+
reason: `tower_send${message?.id ? ` ${message.id}` : ''}: ${args.subject.trim().slice(0, 120)}`,
|
|
116
|
+
})
|
|
117
|
+
} catch (error) {
|
|
118
|
+
results.push({ target: targetName, pushed: false, reason: `interrupt failed: ${String(error?.message ?? error)}` })
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
await subagents.sendMessage(
|
|
124
|
+
exec.agent,
|
|
125
|
+
entry.agentId,
|
|
126
|
+
[
|
|
127
|
+
{
|
|
128
|
+
type: 'text',
|
|
129
|
+
text: `[tower inbox ${message.id}] ${message.subject}\n\n${message.body}`,
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
{ signal: exec.signal },
|
|
133
|
+
)
|
|
134
|
+
results.push({ target: targetName, pushed: true, interrupted })
|
|
135
|
+
} catch (error) {
|
|
136
|
+
results.push({ target: targetName, pushed: false, reason: String(error?.message ?? error) })
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (results.length > 0) await tower.recordDelivery(message.id, results)
|
|
141
|
+
return {
|
|
142
|
+
message: { id: message.id, from, to: message.to, subject: message.subject },
|
|
143
|
+
deliveries: results,
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
}
|
package/tools/spawn.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// tower_spawn: provision and start a worker/reviewer.
|
|
2
|
+
import { Tower } from '../app/tower.js'
|
|
3
|
+
import { SCHEMAS, output } from '../domain/contracts/outputs.js'
|
|
4
|
+
import { TowerToolError, classifyError } from '../domain/errors.js'
|
|
5
|
+
import { SpawnInput } from '../domain/contracts/tool-inputs/index.js'
|
|
6
|
+
import { DEFAULT_ROLE, ROLES as ROLE_ENUM, SPAWN_OUTCOME, isCompletedStopReason, isReviewerRole, isSpawnAdmission, isWorkerRole } from '../domain/vocabulary.js'
|
|
7
|
+
import {
|
|
8
|
+
assertSpawnableMission,
|
|
9
|
+
precheckSpawnServices,
|
|
10
|
+
precheckWorkerOwnership,
|
|
11
|
+
resolveRosterName,
|
|
12
|
+
} from '../lib/spawn-prechecks.js'
|
|
13
|
+
import { buildAgentOptions, buildSpawnBriefing, waitForSpawnResult } from '../lib/spawn-support.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {any} ctx
|
|
17
|
+
* @param {{
|
|
18
|
+
* define: import('../types/index.js').DefineTool,
|
|
19
|
+
* actorFor: any,
|
|
20
|
+
* requireTowerActor: any,
|
|
21
|
+
* enqueueSpawn: any,
|
|
22
|
+
* observeActivity: any,
|
|
23
|
+
* spawnBackoffState: any,
|
|
24
|
+
* recordSpawnThrottle: any,
|
|
25
|
+
* recordSpawnSuccess: any,
|
|
26
|
+
* runMissionDiscovery: any,
|
|
27
|
+
* textOf: any,
|
|
28
|
+
* }} deps
|
|
29
|
+
*/
|
|
30
|
+
export function registerSpawnTool(ctx, deps) {
|
|
31
|
+
const {
|
|
32
|
+
define, actorFor, requireTowerActor, enqueueSpawn, observeActivity,
|
|
33
|
+
spawnBackoffState, recordSpawnThrottle, recordSpawnSuccess,
|
|
34
|
+
runMissionDiscovery, textOf,
|
|
35
|
+
} = deps
|
|
36
|
+
define(ctx, {
|
|
37
|
+
name: 'tower_spawn',
|
|
38
|
+
description:
|
|
39
|
+
'Spawn a worker or reviewer for a mission. Workers use the tower-worker preset; reviewers require a completed mission and use the read-only tower-reviewer preset. Provisions (or reuses) the mission git worktree and branch, generates a self-contained briefing, and starts a background subagent in that worktree. The child final report is delivered back to this session; tower_roster shows authoritative activity. Records the agent in the roster; reviewer spawns record the branch tip so a reviewer that modifies the branch cannot submit a review. Returns immediately unless wait=true.',
|
|
40
|
+
parameters: SpawnInput,
|
|
41
|
+
aliases: {"missionId":["id"],"waitMs":["wait_ms"]},
|
|
42
|
+
requiresMissionId: true,
|
|
43
|
+
board: true,
|
|
44
|
+
output: output(SCHEMAS.spawn),
|
|
45
|
+
async execute(args, exec) {
|
|
46
|
+
const tower = await Tower.open(args.repoRoot)
|
|
47
|
+
const actor = await actorFor(tower, exec)
|
|
48
|
+
requireTowerActor(actor, 'spawn tower agents')
|
|
49
|
+
const role = args.role ?? DEFAULT_ROLE
|
|
50
|
+
if (!ROLE_ENUM.includes(role)) throw new TowerToolError('invalid-argument', `role must be one of ${ROLE_ENUM.join('|')}`)
|
|
51
|
+
// Repository config (if any) already overrides the profile config here.
|
|
52
|
+
const config = tower.config
|
|
53
|
+
const preset = isReviewerRole(role) ? config.spawn.reviewerPreset : config.spawn.workerPreset
|
|
54
|
+
const model = isReviewerRole(role) ? config.spawn.reviewerModel : config.spawn.workerModel
|
|
55
|
+
const context = await tower.missionContext(args.missionId)
|
|
56
|
+
assertSpawnableMission({ role, context })
|
|
57
|
+
const spawned = await enqueueSpawn(tower.root, async () => {
|
|
58
|
+
const observed = await observeActivity(ctx, tower)
|
|
59
|
+
await tower.reconcileObservedInactive(observed.activity)
|
|
60
|
+
const backoff = spawnBackoffState(tower.root, config.spawn.maxLiveWorkers)
|
|
61
|
+
if (backoff.blockedUntil > Date.now()) {
|
|
62
|
+
const seconds = Math.ceil((backoff.blockedUntil - Date.now()) / 1000)
|
|
63
|
+
throw new TowerToolError('provider-cooldown', `provider cooldown active; retry in ${seconds}s (${backoff.failures} recent failures)`)
|
|
64
|
+
}
|
|
65
|
+
const view = await tower.rosterView()
|
|
66
|
+
const name = resolveRosterName({ role, missionId: args.missionId, requested: args.name, roster: view.roster })
|
|
67
|
+
const takeover = await precheckWorkerOwnership({
|
|
68
|
+
role, tower, ctx, view, missionId: args.missionId, takeover: args.takeover,
|
|
69
|
+
maxLiveWorkers: config.spawn.maxLiveWorkers, budget: backoff.budget, observeActivity,
|
|
70
|
+
})
|
|
71
|
+
const { subagents, provider } = await precheckSpawnServices({ tower, ctx, config, preset, actor, exec })
|
|
72
|
+
|
|
73
|
+
const provisioned = await tower.provision({
|
|
74
|
+
missionId: args.missionId,
|
|
75
|
+
worktreeRoot: args.worktreeRoot,
|
|
76
|
+
includeDirtyBase: args.includeDirtyBase !== false,
|
|
77
|
+
})
|
|
78
|
+
const { mission, worktree } = provisioned
|
|
79
|
+
const briefing = buildSpawnBriefing({ role, towerRoot: tower.root, context, worktree, instructions: args.instructions })
|
|
80
|
+
const agentOptions = buildAgentOptions(model)
|
|
81
|
+
|
|
82
|
+
if (isWorkerRole(role) && isSpawnAdmission(tower.config.tests?.admission)) {
|
|
83
|
+
const discovery = await runMissionDiscovery(tower, mission)
|
|
84
|
+
if (discovery.configured && discovery.receipt.ok !== true) {
|
|
85
|
+
throw new TowerToolError(
|
|
86
|
+
'tests-failed',
|
|
87
|
+
`test admission failed for ${mission.id}: ${discovery.receipt.error}; no child was started`,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let activation = null
|
|
93
|
+
let agentId = null
|
|
94
|
+
let entry = null
|
|
95
|
+
await tower.reserveRoster({
|
|
96
|
+
missionId: mission.id,
|
|
97
|
+
name,
|
|
98
|
+
role,
|
|
99
|
+
parentSessionId: String(exec.agent.id),
|
|
100
|
+
cwd: worktree.path,
|
|
101
|
+
branch: worktree.branch,
|
|
102
|
+
})
|
|
103
|
+
try {
|
|
104
|
+
activation = await subagents.startActivation({
|
|
105
|
+
provider,
|
|
106
|
+
label: name,
|
|
107
|
+
request: {
|
|
108
|
+
cwd: worktree.path,
|
|
109
|
+
agentPreset: preset,
|
|
110
|
+
prompt: [{ type: 'text', text: briefing }],
|
|
111
|
+
parent: exec.agent,
|
|
112
|
+
maxDepth: config.spawn.maxDepth ?? 1,
|
|
113
|
+
...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
|
|
114
|
+
},
|
|
115
|
+
signal: exec.signal,
|
|
116
|
+
delivery: 'parent',
|
|
117
|
+
})
|
|
118
|
+
agentId = String(activation.childId)
|
|
119
|
+
entry = await tower.recordSpawn({
|
|
120
|
+
missionId: mission.id,
|
|
121
|
+
name,
|
|
122
|
+
role,
|
|
123
|
+
agentId,
|
|
124
|
+
parentSessionId: String(exec.agent.id),
|
|
125
|
+
cwd: worktree.path,
|
|
126
|
+
branch: worktree.branch,
|
|
127
|
+
takeover,
|
|
128
|
+
})
|
|
129
|
+
recordSpawnSuccess(tower.root, config.spawn.maxLiveWorkers)
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (classifyError(error).retryable === true) {
|
|
132
|
+
recordSpawnThrottle(tower.root, config.spawn.maxLiveWorkers)
|
|
133
|
+
}
|
|
134
|
+
// The child may already be running even when recordSpawn failed: stop
|
|
135
|
+
// it and mark the reserved entry failed so it cannot keep writing
|
|
136
|
+
// unobserved.
|
|
137
|
+
if (agentId) {
|
|
138
|
+
try {
|
|
139
|
+
await Promise.resolve(subagents.interrupt(agentId, { kind: 'ancestor', agent: exec.agent }))
|
|
140
|
+
} catch {
|
|
141
|
+
// best effort; the error below is the actionable one
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
await tower
|
|
145
|
+
.recordSpawnOutcome({
|
|
146
|
+
missionId: mission.id,
|
|
147
|
+
name,
|
|
148
|
+
state: SPAWN_OUTCOME.failed,
|
|
149
|
+
detail: `spawn failed${agentId ? ` after activation ${agentId}` : ' before activation'}: ${String(error?.message ?? error).slice(0, 300)}`,
|
|
150
|
+
})
|
|
151
|
+
.catch(() => {})
|
|
152
|
+
throw error
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const recordOutcome = (state, detail) =>
|
|
156
|
+
tower.recordSpawnOutcome({ missionId: mission.id, name, state, detail }).catch(() => {})
|
|
157
|
+
|
|
158
|
+
const report = {
|
|
159
|
+
name,
|
|
160
|
+
agentId,
|
|
161
|
+
role,
|
|
162
|
+
missionId: mission.id,
|
|
163
|
+
preset,
|
|
164
|
+
provider,
|
|
165
|
+
maxDepth: config.spawn.maxDepth ?? 1,
|
|
166
|
+
...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
|
|
167
|
+
worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
|
|
168
|
+
created: provisioned.created,
|
|
169
|
+
briefingChars: briefing.length,
|
|
170
|
+
takeover,
|
|
171
|
+
rosterEntry: entry,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { report, activation, recordOutcome }
|
|
175
|
+
})
|
|
176
|
+
const { report, activation, recordOutcome } = spawned
|
|
177
|
+
if (args.wait !== true) {
|
|
178
|
+
activation.result
|
|
179
|
+
.then((result) => {
|
|
180
|
+
const state = isCompletedStopReason(result?.stopReason) ? SPAWN_OUTCOME.inactive : SPAWN_OUTCOME.failed
|
|
181
|
+
return recordOutcome(state, result?.diagnostic ?? result?.stopReason ?? null)
|
|
182
|
+
})
|
|
183
|
+
.catch((error) => recordOutcome(SPAWN_OUTCOME.failed, String(error?.message ?? error)))
|
|
184
|
+
return report
|
|
185
|
+
}
|
|
186
|
+
return waitForSpawnResult({
|
|
187
|
+
activation,
|
|
188
|
+
report,
|
|
189
|
+
recordOutcome,
|
|
190
|
+
maxWaitMs: config.spawn.maxWaitMs ?? 90000,
|
|
191
|
+
requestedWaitMs: args.waitMs ?? 180000,
|
|
192
|
+
textOf,
|
|
193
|
+
})
|
|
194
|
+
},
|
|
195
|
+
})
|
|
196
|
+
}
|
package/tools/status.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// tower_status: dashboard and mission detail.
|
|
2
|
+
import { Tower } from '../app/tower.js'
|
|
3
|
+
import { SCHEMAS, output } from '../domain/contracts/outputs.js'
|
|
4
|
+
import { classifyError } from '../domain/errors.js'
|
|
5
|
+
import { StatusInput } from '../domain/contracts/tool-inputs/index.js'
|
|
6
|
+
import { ROSTER_INACTIVE_STATES, isMarkdownFormat, isReservedState, isWorkerRole } from '../domain/vocabulary.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {any} ctx
|
|
10
|
+
* @param {{
|
|
11
|
+
* define: import('../types/index.js').DefineTool,
|
|
12
|
+
* workspaceHook: any,
|
|
13
|
+
* spawnBackoffView: any,
|
|
14
|
+
* observeActivity: any,
|
|
15
|
+
* }} deps
|
|
16
|
+
*/
|
|
17
|
+
export function registerStatusTool(ctx, deps) {
|
|
18
|
+
const { define, workspaceHook, spawnBackoffView, observeActivity } = deps
|
|
19
|
+
define(ctx, {
|
|
20
|
+
name: 'tower_status',
|
|
21
|
+
description:
|
|
22
|
+
'Read the Tower dashboard for a repository: state path, base branch, revision, mission status counts, paged mission summaries (scope/deps/tasks/worktree/owner), roster size, findings, config path and the activity tail. Pass missionId for one full mission (tasks, notes, reviews, findings, completion report); offset/limit page the mission list (default 20, max 100); includeGate adds preflight gate digests for the page; format:"markdown" returns the human-readable mission board. Read-only.',
|
|
23
|
+
parameters: StatusInput,
|
|
24
|
+
aliases: { mission_id: ['missionId'], include_gate: ['includeGate'] },
|
|
25
|
+
output: output(SCHEMAS.status),
|
|
26
|
+
async execute(args, exec) {
|
|
27
|
+
const tower = await Tower.open(args.repoRoot)
|
|
28
|
+
const boardPath = tower.boardFile()
|
|
29
|
+
const mode = workspaceHook?.modeInfo?.(exec) ?? null
|
|
30
|
+
const statusState = await tower.load()
|
|
31
|
+
const inflight = (statusState.roster ?? []).filter(
|
|
32
|
+
(entry) => isWorkerRole(entry.role) && !ROSTER_INACTIVE_STATES.includes(entry.state) && !isReservedState(entry.state),
|
|
33
|
+
).length
|
|
34
|
+
const spawn = spawnBackoffView(tower.root, tower.config.spawn?.maxLiveWorkers, inflight)
|
|
35
|
+
if (args.missionId) {
|
|
36
|
+
const [status, detail] = await Promise.all([
|
|
37
|
+
tower.status({ missionIds: [args.missionId] }),
|
|
38
|
+
tower.missionDetail(args.missionId),
|
|
39
|
+
])
|
|
40
|
+
return {
|
|
41
|
+
...status,
|
|
42
|
+
missions: status.missions.filter((mission) => mission.id === args.missionId),
|
|
43
|
+
missionId: args.missionId,
|
|
44
|
+
detail,
|
|
45
|
+
boardPath,
|
|
46
|
+
mode,
|
|
47
|
+
spawn,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const state = await tower.load()
|
|
51
|
+
const total = state.missions.length
|
|
52
|
+
const offset = typeof args.offset === 'number' && Number.isInteger(args.offset) && args.offset > 0 ? args.offset : 0
|
|
53
|
+
const limit = typeof args.limit === 'number' && Number.isInteger(args.limit) ? Math.max(1, Math.min(100, args.limit)) : 20
|
|
54
|
+
const pageIds = state.missions.slice(offset, offset + limit).map((mission) => mission.id)
|
|
55
|
+
const status = await tower.status({ missionIds: pageIds })
|
|
56
|
+
let page = status.missions
|
|
57
|
+
if (args.includeGate === true) {
|
|
58
|
+
const { activity, parentStatus } = await observeActivity(ctx, tower)
|
|
59
|
+
page = await Promise.all(
|
|
60
|
+
page.map(async (mission) => {
|
|
61
|
+
try {
|
|
62
|
+
const gate = await tower.evaluateGate({ missionId: mission.id, activity, parentStatus })
|
|
63
|
+
return {
|
|
64
|
+
...mission,
|
|
65
|
+
gate: {
|
|
66
|
+
ok: gate.ok,
|
|
67
|
+
blockers: (gate.blockers ?? []).map((blocker) => ({ code: blocker.code, detail: blocker.detail })),
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { ...mission, gate: { ok: false, error: classifyError(error).message } }
|
|
72
|
+
}
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
const result = {
|
|
77
|
+
...status,
|
|
78
|
+
missions: page,
|
|
79
|
+
total,
|
|
80
|
+
offset,
|
|
81
|
+
nextOffset: offset + page.length < total ? offset + page.length : null,
|
|
82
|
+
boardPath,
|
|
83
|
+
mode,
|
|
84
|
+
spawn,
|
|
85
|
+
}
|
|
86
|
+
if (isMarkdownFormat(args.format)) {
|
|
87
|
+
const state = await tower.load()
|
|
88
|
+
const output = /** @type {any} */ (result)
|
|
89
|
+
output.markdown = tower.boardMarkdown(state)
|
|
90
|
+
}
|
|
91
|
+
return result
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// tower_teardown — remove eligible mission worktrees.
|
|
2
|
+
//
|
|
3
|
+
// Input: TeardownInput (dryRun, force, exclude) from contracts.
|
|
4
|
+
// Output: removed/kept decisions for every mission.
|
|
5
|
+
// Authorization: tower only; live agents always keep their worktree.
|
|
6
|
+
// Side effects: removes worktrees/branches, persists activity.
|
|
7
|
+
// Failure modes: dirty without force, branch delete failure.
|
|
8
|
+
// Pure eligibility decisions live in domain/integration.
|
|
9
|
+
// See docs/reference/tools.generated.md for the public shape.
|
|
10
|
+
// tower_teardown: remove eligible mission worktrees.
|
|
11
|
+
import { Tower } from '../app/tower.js'
|
|
12
|
+
import { SCHEMAS, output } from '../domain/contracts/outputs.js'
|
|
13
|
+
import { TeardownInput } from '../domain/contracts/tool-inputs/index.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {any} ctx
|
|
17
|
+
* @param {{ define: import('../types/index.js').DefineTool, actorFor: any, requireTowerActor: any, observeActivity: any }} deps
|
|
18
|
+
*/
|
|
19
|
+
export function registerTeardownTool(ctx, deps) {
|
|
20
|
+
const { define, actorFor, requireTowerActor, observeActivity } = deps
|
|
21
|
+
define(ctx, {
|
|
22
|
+
name: 'tower_teardown',
|
|
23
|
+
description:
|
|
24
|
+
'Tear down eligible mission worktrees after their missions are merged or abandoned. A worktree whose roster agent is running is always kept (force cannot override); dirty worktrees need force; exclude keeps named missions. dry_run previews the remove/keep decisions without changing anything.',
|
|
25
|
+
parameters: TeardownInput,
|
|
26
|
+
aliases: {"dry_run":["dryRun"]},
|
|
27
|
+
board: true,
|
|
28
|
+
output: output(SCHEMAS.teardown),
|
|
29
|
+
async execute(args, exec) {
|
|
30
|
+
const tower = await Tower.open(args.repoRoot)
|
|
31
|
+
const actor = await actorFor(tower, exec)
|
|
32
|
+
requireTowerActor(actor, 'tear down worktrees')
|
|
33
|
+
const { activity } = await observeActivity(ctx, tower)
|
|
34
|
+
return tower.teardown({
|
|
35
|
+
force: args.force === true,
|
|
36
|
+
exclude: args.exclude,
|
|
37
|
+
dryRun: args.dry_run === true,
|
|
38
|
+
activity,
|
|
39
|
+
})
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
}
|